Skip to content

Commit 73b4923

Browse files
authored
Merge pull request #41 from mkXultra/feat/peek
feat: Add peek command for one-shot observation of running agent messages
2 parents 15f0594 + c12fd4c commit 73b4923

18 files changed

Lines changed: 1332 additions & 11 deletions

README.ja.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --session-id ses_123 --prompt
124124
ai-cli ps
125125
ai-cli result 12345
126126
ai-cli result 12345 --verbose
127+
ai-cli peek 12345 --time 10
127128
ai-cli wait 12345 --timeout 300
128129
ai-cli wait 12345 --verbose
129130
ai-cli kill 12345
@@ -178,6 +179,7 @@ macOSでは、これらのツールを初めて実行する際にフォルダへ
178179
- `run`
179180
- `ps`
180181
- `result`
182+
- `peek`
181183
- `wait`
182184
- `kill`
183185
- `cleanup`
@@ -194,6 +196,8 @@ ai-cli run --cwd "$PWD" --model codex-ultra --prompt "fix failing tests"
194196
ai-cli run --cwd "$PWD" --model opencode --session-id ses_existing --prompt "この OpenCode セッションを継続して"
195197
ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --prompt "明示的な OpenCode モデルで実行"
196198
ai-cli ps
199+
ai-cli peek 12345 --time 10
200+
ai-cli peek 12345 12346 --time 10
197201
ai-cli wait 12345
198202
ai-cli wait 12345 --verbose
199203
ai-cli result 12345
@@ -272,6 +276,60 @@ Claude CLI、Codex CLI、Gemini CLI、Forge CLI、または OpenCode を使用
272276
- `timeout` (number, 任意): 最大待機時間(秒)。デフォルトは180秒(3分)です。
273277
- `verbose` (boolean, 任意): `true` の場合、各結果項目を full 形で返します。デフォルトは `false` です。
274278

279+
### `peek`
280+
281+
実行中の子エージェントを短時間だけ観測し、その `peek` 呼び出しの観測ウィンドウ内で ai-cli-mcp が受理した自然言語メッセージだけを返します。履歴APIではなく、欠落のないストリーミングでもなく、シェルの `stdout` / `stderr` tail でもありません。別々の `peek` 呼び出しの間に出たメッセージは取得できない場合があります。v1 では `--follow` はありません。
282+
283+
CLI v1:
284+
285+
```bash
286+
ai-cli peek 123 --time 10
287+
ai-cli peek 123 456 --time 10
288+
```
289+
290+
**引数:**
291+
- `pids` (array of numbers, 必須): `run` が返したプロセスIDを 1..32 件指定します。重複したPIDはサーバー側で重複排除され、最初に出た順序が維持されます。未知または管理外のPIDは、呼び出し全体の失敗ではなく、プロセスごとに `not_found` として返されます。
292+
- `peek_time_sec` (number, 任意): 観測時間(秒)の正の整数です。デフォルトは10秒、最大60秒です。`0`、負数、小数は無効です。
293+
294+
**観測とフィルタリング:**
295+
- `peek_started_at``messages[].ts` は、ai-cli-mcp サーバー側の UTC RFC3339 タイムスタンプです。`peek_started_at` は検証とリスナー登録後に観測ウィンドウが始まった時刻、`messages[].ts` は ai-cli-mcp がメッセージを観測して受理した時刻です。
296+
- 観測ウィンドウは `peek_time_sec` が経過するか、対象プロセスがすべて終端状態になった時点で終了します。
297+
- 観測開始前のメッセージは返しません。同じPIDへの同時 `peek` は可能で、それぞれ独立した観測ウィンドウを持つため、メッセージが重複して返ることがあります。
298+
- 返すのは認識済みの自然言語メッセージだけです。Codex の `agent_message` text、Claude assistant の text content、OpenCode の `type: "text"` かつ `part.type``"text"` のイベント、Gemini stream-json の `role``"assistant"``message` イベントを含めます。raw `stdout` / `stderr`、raw JSONL、reasoning、`tool_use``tool_result`、コマンドの `stdout` / `stderr`、command execution メタデータ、token usage、verbose メタデータは除外します。
299+
- 未知のイベント形状はデフォルトで拒否します。Forge など、自然言語抽出がまだ明示対応されていない管理対象エージェントは、実際のプロセス状態を返しつつ、`messages: []``truncated: false``error: null` にします。
300+
- 各PIDごとに、観測ウィンドウ内で最初に観測された50件までを保持します。それ以降のメッセージを捨てた場合は `truncated``true` になります。
301+
- `status``running``completed``failed``not_found` のいずれかで、観測ウィンドウ終了時点の状態を表します。
302+
- `agent``claude``codex``gemini``forge``opencode`、将来追加される追跡済みエージェント文字列、または `null` です。`null` はプロセスが見つからない、またはエージェント種別を判断できない場合を表します。
303+
304+
レスポンス例:
305+
306+
```json
307+
{
308+
"peek_started_at": "2026-04-11T12:34:56.789Z",
309+
"observed_duration_sec": 10.01,
310+
"processes": [
311+
{
312+
"pid": 123,
313+
"agent": "codex",
314+
"status": "running",
315+
"messages": [
316+
{ "ts": "2026-04-11T12:34:59.120Z", "text": "I'm checking the implementation." }
317+
],
318+
"truncated": false,
319+
"error": null
320+
},
321+
{
322+
"pid": 999,
323+
"agent": null,
324+
"status": "not_found",
325+
"messages": [],
326+
"truncated": false,
327+
"error": "process not found"
328+
}
329+
]
330+
}
331+
```
332+
275333
### `list_processes`
276334

277335
実行中および完了したすべてのAIエージェントプロセスを、ステータス、PID、基本情報とともにリストアップします。

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --session-id ses_123 --prompt
121121
ai-cli ps
122122
ai-cli result 12345
123123
ai-cli result 12345 --verbose
124+
ai-cli peek 12345 --time 10
124125
ai-cli wait 12345 --timeout 300
125126
ai-cli wait 12345 --verbose
126127
ai-cli kill 12345
@@ -175,6 +176,7 @@ macOS might ask for folder permissions the first time any of these tools run. If
175176
- `run`
176177
- `ps`
177178
- `result`
179+
- `peek`
178180
- `wait`
179181
- `kill`
180182
- `cleanup`
@@ -191,6 +193,8 @@ ai-cli run --cwd "$PWD" --model codex-ultra --prompt "fix failing tests"
191193
ai-cli run --cwd "$PWD" --model opencode --session-id ses_existing --prompt "continue this OpenCode session"
192194
ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --prompt "run with an explicit OpenCode backend model"
193195
ai-cli ps
196+
ai-cli peek 12345 --time 10
197+
ai-cli peek 12345 12346 --time 10
194198
ai-cli wait 12345
195199
ai-cli wait 12345 --verbose
196200
ai-cli result 12345
@@ -269,6 +273,60 @@ By default, each returned result item uses the compact shape shared with `get_re
269273
- `timeout` (number, optional): Maximum wait time in seconds. Defaults to 180 (3 minutes).
270274
- `verbose` (boolean, optional): If `true`, each result item uses the full result shape. Defaults to `false`.
271275

276+
### `peek`
277+
278+
Starts a one-shot short observation window for running child agents and returns only natural-language agent messages observed during that specific call. It is not a history API, not gapless streaming, and not shell stdout/stderr tailing. Separate `peek` calls may miss messages emitted between calls; `--follow` is intentionally not part of v1.
279+
280+
CLI v1:
281+
282+
```bash
283+
ai-cli peek 123 --time 10
284+
ai-cli peek 123 456 --time 10
285+
```
286+
287+
**Arguments:**
288+
- `pids` (array of numbers, required): 1..32 process IDs returned by `run`. Duplicate PIDs are deduplicated server-side, preserving first occurrence order. Unknown or unmanaged PIDs are returned per process as `not_found`, not as a whole-call failure.
289+
- `peek_time_sec` (number, optional): Positive integer observation length in seconds. Defaults to 10 and is capped at 60. `0`, negative values, and fractional values are invalid.
290+
291+
**Observation and filtering:**
292+
- `peek_started_at` and `messages[].ts` are ai-cli-mcp server-side UTC RFC3339 timestamps. `peek_started_at` is when the observation window starts after validation and listener registration; `messages[].ts` is when ai-cli-mcp observed and accepted the message.
293+
- The window ends when `peek_time_sec` elapses or all target processes reach a terminal state, whichever comes first.
294+
- Messages emitted before the window starts are not returned. Concurrent `peek` calls for the same PID are allowed; each has an independent window and may return overlapping messages.
295+
- Only recognized natural-language agent messages are returned: Codex `agent_message` text, Claude assistant text content, OpenCode `type: "text"` events where `part.type` is `"text"`, and Gemini stream-json `message` events where `role` is `"assistant"`. Raw stdout/stderr, raw JSONL, reasoning, `tool_use`, `tool_result`, command stdout/stderr, command execution metadata, token usage, and verbose metadata are excluded.
296+
- Unknown event shapes are denied by default. Managed agents without supported natural-language extraction, such as Forge until explicitly supported, return their real process status with `messages: []`, `truncated: false`, and `error: null`.
297+
- Each PID keeps the first 50 messages observed in the window. If later messages are dropped, `truncated` is `true`.
298+
- `status` is one of `running`, `completed`, `failed`, or `not_found`, and reflects state when the observation window closes.
299+
- `agent` is `claude`, `codex`, `gemini`, `forge`, `opencode`, a future tracked string value, or `null` when the process is not found or the agent cannot be determined.
300+
301+
Example response:
302+
303+
```json
304+
{
305+
"peek_started_at": "2026-04-11T12:34:56.789Z",
306+
"observed_duration_sec": 10.01,
307+
"processes": [
308+
{
309+
"pid": 123,
310+
"agent": "codex",
311+
"status": "running",
312+
"messages": [
313+
{ "ts": "2026-04-11T12:34:59.120Z", "text": "I'm checking the implementation." }
314+
],
315+
"truncated": false,
316+
"error": null
317+
},
318+
{
319+
"pid": 999,
320+
"agent": null,
321+
"status": "not_found",
322+
"messages": [],
323+
"truncated": false,
324+
"error": "process not found"
325+
}
326+
]
327+
}
328+
```
329+
272330
### `list_processes`
273331

274332
Lists all running and completed AI agent processes with their status, PID, and basic info.

src/__tests__/app-cli.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
CLI_HELP_TEXT,
44
DOCTOR_HELP_TEXT,
55
MODELS_HELP_TEXT,
6+
PEEK_HELP_TEXT,
67
RESULT_HELP_TEXT,
78
RUN_HELP_TEXT,
89
WAIT_HELP_TEXT,
@@ -199,6 +200,64 @@ describe('ai-cli app', () => {
199200
expect(waitForProcesses).not.toHaveBeenCalled();
200201
});
201202

203+
it('dispatches peek with deduped pid arguments and time', async () => {
204+
const stdout = vi.fn();
205+
const stderr = vi.fn();
206+
const peekProcesses = vi.fn().mockResolvedValue({
207+
peek_started_at: '2026-04-11T12:34:56.789Z',
208+
observed_duration_sec: 0.01,
209+
processes: [],
210+
});
211+
212+
const exitCode = await runCli(
213+
['peek', '123', '456', '123', '--time', '5'],
214+
{
215+
stdout,
216+
stderr,
217+
peekProcesses,
218+
}
219+
);
220+
221+
expect(exitCode).toBe(0);
222+
expect(peekProcesses).toHaveBeenCalledWith([123, 456], 5);
223+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"peek_started_at"'));
224+
expect(stderr).not.toHaveBeenCalled();
225+
});
226+
227+
it('defaults peek time and rejects --follow', async () => {
228+
const stdout = vi.fn();
229+
const stderr = vi.fn();
230+
const peekProcesses = vi.fn().mockResolvedValue({
231+
peek_started_at: '2026-04-11T12:34:56.789Z',
232+
observed_duration_sec: 0.01,
233+
processes: [],
234+
});
235+
236+
const defaultExitCode = await runCli(['peek', '123'], { stdout, stderr, peekProcesses });
237+
expect(defaultExitCode).toBe(0);
238+
expect(peekProcesses).toHaveBeenCalledWith([123], 10);
239+
240+
const followExitCode = await runCli(['peek', '123', '--follow'], { stdout, stderr, peekProcesses });
241+
expect(followExitCode).toBe(1);
242+
expect(stderr).toHaveBeenCalledWith('peek does not support --follow in v1\n');
243+
});
244+
245+
it('rejects invalid peek time values', async () => {
246+
const stdout = vi.fn();
247+
const stderr = vi.fn();
248+
const peekProcesses = vi.fn();
249+
250+
const exitCode = await runCli(['peek', '123', '--time', '1.5'], {
251+
stdout,
252+
stderr,
253+
peekProcesses,
254+
});
255+
256+
expect(exitCode).toBe(1);
257+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('peek_time_sec must be a positive integer'));
258+
expect(peekProcesses).not.toHaveBeenCalled();
259+
});
260+
202261
it('dispatches ps, result, and kill', async () => {
203262
const stdout = vi.fn();
204263
const stderr = vi.fn();
@@ -354,6 +413,18 @@ describe('ai-cli app', () => {
354413
expect(stderr).not.toHaveBeenCalled();
355414
});
356415

416+
it('prints detailed help for peek --help', async () => {
417+
const stdout = vi.fn();
418+
const stderr = vi.fn();
419+
420+
const exitCode = await runCli(['peek', '--help'], { stdout, stderr });
421+
422+
expect(exitCode).toBe(0);
423+
expect(stdout).toHaveBeenCalledWith(PEEK_HELP_TEXT);
424+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('No --follow mode'));
425+
expect(stderr).not.toHaveBeenCalled();
426+
});
427+
357428
it('prints detailed help for models --help', async () => {
358429
const stdout = vi.fn();
359430
const stderr = vi.fn();

src/__tests__/cli-builder.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ describe('cli-builder', () => {
388388
expect(cmd.cliPath).toBe('/usr/bin/gemini');
389389
expect(cmd.args).toContain('-y');
390390
expect(cmd.args).toContain('--output-format');
391-
expect(cmd.args).toContain('json');
391+
expect(cmd.args).toContain('stream-json');
392392
expect(cmd.args).toContain('--model');
393393
expect(cmd.args).toContain('gemini-2.5-pro');
394394
});

src/__tests__/cli-process-service.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,74 @@ describe('CliProcessService', () => {
122122
expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
123123
});
124124

125+
it('peeks only appended natural-language messages from detached logs', async () => {
126+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
127+
tempDirs.push(root);
128+
const scriptPath = join(root, 'mock-claude-peek');
129+
writeFileSync(
130+
scriptPath,
131+
`#!/bin/bash
132+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
133+
sleep 2
134+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"new cli message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}'
135+
printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
136+
`
137+
);
138+
chmodSync(scriptPath, 0o755);
139+
const stateDir = join(root, 'state');
140+
const workFolder = join(root, 'work');
141+
mkdirSync(workFolder, { recursive: true });
142+
143+
const service = new CliProcessService({
144+
stateDir,
145+
cliPaths: {
146+
claude: scriptPath,
147+
codex: scriptPath,
148+
gemini: scriptPath,
149+
forge: scriptPath,
150+
opencode: scriptPath,
151+
},
152+
});
153+
154+
const runResult = await service.startProcess({
155+
prompt: 'hello peek',
156+
cwd: workFolder,
157+
});
158+
159+
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
160+
const stdoutPath = join(processDir, 'stdout.log');
161+
const startedAt = Date.now();
162+
while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
163+
await new Promise((resolve) => setTimeout(resolve, 25));
164+
}
165+
expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
166+
167+
const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
168+
169+
expect(peekResult.processes).toHaveLength(2);
170+
expect(peekResult.processes[0]).toMatchObject({
171+
pid: runResult.pid,
172+
agent: 'claude',
173+
status: 'completed',
174+
messages: [
175+
{
176+
ts: expect.any(String),
177+
text: 'new cli message',
178+
},
179+
],
180+
truncated: false,
181+
error: null,
182+
});
183+
expect(peekResult.processes[1]).toEqual({
184+
pid: 999999,
185+
agent: null,
186+
status: 'not_found',
187+
messages: [],
188+
truncated: false,
189+
error: 'process not found',
190+
});
191+
});
192+
125193
it('returns compact results by default and full results when verbose is true', async () => {
126194
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
127195
tempDirs.push(root);

src/__tests__/e2e.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('Claude Code MCP E2E Tests', () => {
3939
it('should register run tool', async () => {
4040
const tools = await client.listTools();
4141

42-
expect(tools).toHaveLength(6);
42+
expect(tools).toHaveLength(7);
4343
const claudeCodeTool = tools.find((t: any) => t.name === 'run');
4444
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('sonnet');
4545
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('opencode');
@@ -49,6 +49,7 @@ describe('Claude Code MCP E2E Tests', () => {
4949
// Verify other tools exist
5050
expect(tools.some((t: any) => t.name === 'list_processes')).toBe(true);
5151
expect(tools.some((t: any) => t.name === 'get_result')).toBe(true);
52+
expect(tools.some((t: any) => t.name === 'peek')).toBe(true);
5253
expect(tools.some((t: any) => t.name === 'kill_process')).toBe(true);
5354
});
5455
});

src/__tests__/mcp-contract.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ describe('MCP Contract Tests', () => {
9696
'get_result',
9797
'kill_process',
9898
'list_processes',
99+
'peek',
99100
'run',
100101
'wait',
101102
]);
@@ -132,6 +133,14 @@ describe('MCP Contract Tests', () => {
132133
'timeout',
133134
'verbose',
134135
]);
136+
137+
const peekTool = tools.find((tool: any) => tool.name === 'peek');
138+
expect(peekTool.inputSchema.required).toEqual(['pids']);
139+
expect(Object.keys(peekTool.inputSchema.properties).sort()).toEqual([
140+
'peek_time_sec',
141+
'pids',
142+
]);
143+
expect(peekTool.description).toContain('One-shot');
135144
});
136145

137146
it('preserves the stdio MCP smoke flow and response shapes', async () => {

0 commit comments

Comments
 (0)