ai-cli-mcp 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [2.16.0](https://github.com/mkXultra/ai-cli-mcp/compare/v2.15.0...v2.16.0) (2026-04-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * peekコマンドを追加 — 実行中エージェントの自然言語メッセージをワンショット観測 ([c12fd4c](https://github.com/mkXultra/ai-cli-mcp/commit/c12fd4cbe374a05b5223191e10fb2144b5d86bd0))
7
+
1
8
  # [2.15.0](https://github.com/mkXultra/ai-cli-mcp/compare/v2.14.1...v2.15.0) (2026-04-09)
2
9
 
3
10
 
package/README.ja.md CHANGED
@@ -124,6 +124,7 @@ ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --session-id ses_123 --prompt
124
124
  ai-cli ps
125
125
  ai-cli result 12345
126
126
  ai-cli result 12345 --verbose
127
+ ai-cli peek 12345 --time 10
127
128
  ai-cli wait 12345 --timeout 300
128
129
  ai-cli wait 12345 --verbose
129
130
  ai-cli kill 12345
@@ -178,6 +179,7 @@ macOSでは、これらのツールを初めて実行する際にフォルダへ
178
179
  - `run`
179
180
  - `ps`
180
181
  - `result`
182
+ - `peek`
181
183
  - `wait`
182
184
  - `kill`
183
185
  - `cleanup`
@@ -194,6 +196,8 @@ ai-cli run --cwd "$PWD" --model codex-ultra --prompt "fix failing tests"
194
196
  ai-cli run --cwd "$PWD" --model opencode --session-id ses_existing --prompt "この OpenCode セッションを継続して"
195
197
  ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --prompt "明示的な OpenCode モデルで実行"
196
198
  ai-cli ps
199
+ ai-cli peek 12345 --time 10
200
+ ai-cli peek 12345 12346 --time 10
197
201
  ai-cli wait 12345
198
202
  ai-cli wait 12345 --verbose
199
203
  ai-cli result 12345
@@ -272,6 +276,60 @@ Claude CLI、Codex CLI、Gemini CLI、Forge CLI、または OpenCode を使用
272
276
  - `timeout` (number, 任意): 最大待機時間(秒)。デフォルトは180秒(3分)です。
273
277
  - `verbose` (boolean, 任意): `true` の場合、各結果項目を full 形で返します。デフォルトは `false` です。
274
278
 
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
+
275
333
  ### `list_processes`
276
334
 
277
335
  実行中および完了したすべてのAIエージェントプロセスを、ステータス、PID、基本情報とともにリストアップします。
package/README.md CHANGED
@@ -121,6 +121,7 @@ ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --session-id ses_123 --prompt
121
121
  ai-cli ps
122
122
  ai-cli result 12345
123
123
  ai-cli result 12345 --verbose
124
+ ai-cli peek 12345 --time 10
124
125
  ai-cli wait 12345 --timeout 300
125
126
  ai-cli wait 12345 --verbose
126
127
  ai-cli kill 12345
@@ -175,6 +176,7 @@ macOS might ask for folder permissions the first time any of these tools run. If
175
176
  - `run`
176
177
  - `ps`
177
178
  - `result`
179
+ - `peek`
178
180
  - `wait`
179
181
  - `kill`
180
182
  - `cleanup`
@@ -191,6 +193,8 @@ ai-cli run --cwd "$PWD" --model codex-ultra --prompt "fix failing tests"
191
193
  ai-cli run --cwd "$PWD" --model opencode --session-id ses_existing --prompt "continue this OpenCode session"
192
194
  ai-cli run --cwd "$PWD" --model oc-openai/gpt-5.4 --prompt "run with an explicit OpenCode backend model"
193
195
  ai-cli ps
196
+ ai-cli peek 12345 --time 10
197
+ ai-cli peek 12345 12346 --time 10
194
198
  ai-cli wait 12345
195
199
  ai-cli wait 12345 --verbose
196
200
  ai-cli result 12345
@@ -269,6 +273,60 @@ By default, each returned result item uses the compact shape shared with `get_re
269
273
  - `timeout` (number, optional): Maximum wait time in seconds. Defaults to 180 (3 minutes).
270
274
  - `verbose` (boolean, optional): If `true`, each result item uses the full result shape. Defaults to `false`.
271
275
 
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
+
272
330
  ### `list_processes`
273
331
 
274
332
  Lists all running and completed AI agent processes with their status, PID, and basic info.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { CLI_HELP_TEXT, DOCTOR_HELP_TEXT, MODELS_HELP_TEXT, RESULT_HELP_TEXT, RUN_HELP_TEXT, WAIT_HELP_TEXT, runCli, } from '../app/cli.js';
2
+ import { CLI_HELP_TEXT, DOCTOR_HELP_TEXT, MODELS_HELP_TEXT, PEEK_HELP_TEXT, RESULT_HELP_TEXT, RUN_HELP_TEXT, WAIT_HELP_TEXT, runCli, } from '../app/cli.js';
3
3
  describe('ai-cli app', () => {
4
4
  it('prints help and exits successfully when no subcommand is provided', async () => {
5
5
  const stdout = vi.fn();
@@ -151,6 +151,52 @@ describe('ai-cli app', () => {
151
151
  expect(stdout).toHaveBeenCalledWith(CLI_HELP_TEXT);
152
152
  expect(waitForProcesses).not.toHaveBeenCalled();
153
153
  });
154
+ it('dispatches peek with deduped pid arguments and time', async () => {
155
+ const stdout = vi.fn();
156
+ const stderr = vi.fn();
157
+ const peekProcesses = vi.fn().mockResolvedValue({
158
+ peek_started_at: '2026-04-11T12:34:56.789Z',
159
+ observed_duration_sec: 0.01,
160
+ processes: [],
161
+ });
162
+ const exitCode = await runCli(['peek', '123', '456', '123', '--time', '5'], {
163
+ stdout,
164
+ stderr,
165
+ peekProcesses,
166
+ });
167
+ expect(exitCode).toBe(0);
168
+ expect(peekProcesses).toHaveBeenCalledWith([123, 456], 5);
169
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"peek_started_at"'));
170
+ expect(stderr).not.toHaveBeenCalled();
171
+ });
172
+ it('defaults peek time and rejects --follow', async () => {
173
+ const stdout = vi.fn();
174
+ const stderr = vi.fn();
175
+ const peekProcesses = vi.fn().mockResolvedValue({
176
+ peek_started_at: '2026-04-11T12:34:56.789Z',
177
+ observed_duration_sec: 0.01,
178
+ processes: [],
179
+ });
180
+ const defaultExitCode = await runCli(['peek', '123'], { stdout, stderr, peekProcesses });
181
+ expect(defaultExitCode).toBe(0);
182
+ expect(peekProcesses).toHaveBeenCalledWith([123], 10);
183
+ const followExitCode = await runCli(['peek', '123', '--follow'], { stdout, stderr, peekProcesses });
184
+ expect(followExitCode).toBe(1);
185
+ expect(stderr).toHaveBeenCalledWith('peek does not support --follow in v1\n');
186
+ });
187
+ it('rejects invalid peek time values', async () => {
188
+ const stdout = vi.fn();
189
+ const stderr = vi.fn();
190
+ const peekProcesses = vi.fn();
191
+ const exitCode = await runCli(['peek', '123', '--time', '1.5'], {
192
+ stdout,
193
+ stderr,
194
+ peekProcesses,
195
+ });
196
+ expect(exitCode).toBe(1);
197
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('peek_time_sec must be a positive integer'));
198
+ expect(peekProcesses).not.toHaveBeenCalled();
199
+ });
154
200
  it('dispatches ps, result, and kill', async () => {
155
201
  const stdout = vi.fn();
156
202
  const stderr = vi.fn();
@@ -281,6 +327,15 @@ describe('ai-cli app', () => {
281
327
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
282
328
  expect(stderr).not.toHaveBeenCalled();
283
329
  });
330
+ it('prints detailed help for peek --help', async () => {
331
+ const stdout = vi.fn();
332
+ const stderr = vi.fn();
333
+ const exitCode = await runCli(['peek', '--help'], { stdout, stderr });
334
+ expect(exitCode).toBe(0);
335
+ expect(stdout).toHaveBeenCalledWith(PEEK_HELP_TEXT);
336
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('No --follow mode'));
337
+ expect(stderr).not.toHaveBeenCalled();
338
+ });
284
339
  it('prints detailed help for models --help', async () => {
285
340
  const stdout = vi.fn();
286
341
  const stderr = vi.fn();
@@ -305,7 +305,7 @@ describe('cli-builder', () => {
305
305
  expect(cmd.cliPath).toBe('/usr/bin/gemini');
306
306
  expect(cmd.args).toContain('-y');
307
307
  expect(cmd.args).toContain('--output-format');
308
- expect(cmd.args).toContain('json');
308
+ expect(cmd.args).toContain('stream-json');
309
309
  expect(cmd.args).toContain('--model');
310
310
  expect(cmd.args).toContain('gemini-2.5-pro');
311
311
  });
@@ -107,6 +107,65 @@ describe('CliProcessService', () => {
107
107
  expect(result).not.toHaveProperty('prompt');
108
108
  expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
109
109
  });
110
+ it('peeks only appended natural-language messages from detached logs', async () => {
111
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
112
+ tempDirs.push(root);
113
+ const scriptPath = join(root, 'mock-claude-peek');
114
+ writeFileSync(scriptPath, `#!/bin/bash
115
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
116
+ sleep 2
117
+ 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"}}]}}'
118
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
119
+ `);
120
+ chmodSync(scriptPath, 0o755);
121
+ const stateDir = join(root, 'state');
122
+ const workFolder = join(root, 'work');
123
+ mkdirSync(workFolder, { recursive: true });
124
+ const service = new CliProcessService({
125
+ stateDir,
126
+ cliPaths: {
127
+ claude: scriptPath,
128
+ codex: scriptPath,
129
+ gemini: scriptPath,
130
+ forge: scriptPath,
131
+ opencode: scriptPath,
132
+ },
133
+ });
134
+ const runResult = await service.startProcess({
135
+ prompt: 'hello peek',
136
+ cwd: workFolder,
137
+ });
138
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
139
+ const stdoutPath = join(processDir, 'stdout.log');
140
+ const startedAt = Date.now();
141
+ while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
142
+ await new Promise((resolve) => setTimeout(resolve, 25));
143
+ }
144
+ expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
145
+ const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
146
+ expect(peekResult.processes).toHaveLength(2);
147
+ expect(peekResult.processes[0]).toMatchObject({
148
+ pid: runResult.pid,
149
+ agent: 'claude',
150
+ status: 'completed',
151
+ messages: [
152
+ {
153
+ ts: expect.any(String),
154
+ text: 'new cli message',
155
+ },
156
+ ],
157
+ truncated: false,
158
+ error: null,
159
+ });
160
+ expect(peekResult.processes[1]).toEqual({
161
+ pid: 999999,
162
+ agent: null,
163
+ status: 'not_found',
164
+ messages: [],
165
+ truncated: false,
166
+ error: 'process not found',
167
+ });
168
+ });
110
169
  it('returns compact results by default and full results when verbose is true', async () => {
111
170
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
112
171
  tempDirs.push(root);
@@ -29,7 +29,7 @@ describe('Claude Code MCP E2E Tests', () => {
29
29
  describe('Tool Registration', () => {
30
30
  it('should register run tool', async () => {
31
31
  const tools = await client.listTools();
32
- expect(tools).toHaveLength(6);
32
+ expect(tools).toHaveLength(7);
33
33
  const claudeCodeTool = tools.find((t) => t.name === 'run');
34
34
  expect(claudeCodeTool.inputSchema.properties.model.description).toContain('sonnet');
35
35
  expect(claudeCodeTool.inputSchema.properties.model.description).toContain('opencode');
@@ -38,6 +38,7 @@ describe('Claude Code MCP E2E Tests', () => {
38
38
  // Verify other tools exist
39
39
  expect(tools.some((t) => t.name === 'list_processes')).toBe(true);
40
40
  expect(tools.some((t) => t.name === 'get_result')).toBe(true);
41
+ expect(tools.some((t) => t.name === 'peek')).toBe(true);
41
42
  expect(tools.some((t) => t.name === 'kill_process')).toBe(true);
42
43
  });
43
44
  });
@@ -84,6 +84,7 @@ describe('MCP Contract Tests', () => {
84
84
  'get_result',
85
85
  'kill_process',
86
86
  'list_processes',
87
+ 'peek',
87
88
  'run',
88
89
  'wait',
89
90
  ]);
@@ -115,6 +116,13 @@ describe('MCP Contract Tests', () => {
115
116
  'timeout',
116
117
  'verbose',
117
118
  ]);
119
+ const peekTool = tools.find((tool) => tool.name === 'peek');
120
+ expect(peekTool.inputSchema.required).toEqual(['pids']);
121
+ expect(Object.keys(peekTool.inputSchema.properties).sort()).toEqual([
122
+ 'peek_time_sec',
123
+ 'pids',
124
+ ]);
125
+ expect(peekTool.description).toContain('One-shot');
118
126
  });
119
127
  it('preserves the stdio MCP smoke flow and response shapes', async () => {
120
128
  const runResponse = await client.callTool('run', {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseOpenCodeOutput } from '../parsers.js';
2
+ import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from '../parsers.js';
3
3
  describe('parseCodexOutput', () => {
4
4
  it('should parse basic Codex output with message and session_id', () => {
5
5
  const output = `
@@ -58,6 +58,168 @@ INVALID_JSON
58
58
  expect(result.message).toBe("Still parses valid lines");
59
59
  });
60
60
  });
61
+ describe('PeekMessageExtractor', () => {
62
+ const ts = '2026-04-11T12:34:56.789Z';
63
+ it('extracts only Codex agent_message text', () => {
64
+ const extractor = new PeekMessageExtractor('codex');
65
+ const output = [
66
+ '{"type":"item.completed","item":{"type":"reasoning","text":"hidden"}}',
67
+ '{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"secret command output"}}',
68
+ '{"type":"item.completed","item":{"type":"agent_message","text":"Visible Codex message"}}',
69
+ '{"msg":{"type":"token_count","total":123}}',
70
+ '{"msg":{"type":"agent_message","message":"Visible legacy Codex message"}}',
71
+ ].join('\n') + '\n';
72
+ expect(extractor.push(output, ts)).toEqual([
73
+ { ts, text: 'Visible Codex message' },
74
+ { ts, text: 'Visible legacy Codex message' },
75
+ ]);
76
+ });
77
+ it('extracts only Claude assistant text content', () => {
78
+ const extractor = new PeekMessageExtractor('claude');
79
+ const output = [
80
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"Visible Claude text"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}',
81
+ '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}',
82
+ '{"type":"result","result":"Final result is not peek assistant text"}',
83
+ ].join('\n') + '\n';
84
+ expect(extractor.push(output, ts)).toEqual([
85
+ { ts, text: 'Visible Claude text' },
86
+ ]);
87
+ });
88
+ it('extracts only Gemini assistant message content', () => {
89
+ const extractor = new PeekMessageExtractor('gemini');
90
+ const output = [
91
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
92
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}',
93
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
94
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
95
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result is not peek assistant text"}',
96
+ ].join('\n') + '\n';
97
+ expect(extractor.push(output, ts)).toEqual([
98
+ { ts, text: 'Visible Gemini text' },
99
+ ]);
100
+ });
101
+ it('joins split Gemini assistant chunks into one peek message on flush', () => {
102
+ const extractor = new PeekMessageExtractor('gemini');
103
+ const output = [
104
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Step 2 done. Starting step ","delta":true}',
105
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"3.","delta":true}',
106
+ ].join('\n') + '\n';
107
+ expect(extractor.push(output, ts)).toEqual([]);
108
+ expect(extractor.flush('2026-04-11T12:34:59.000Z')).toEqual([
109
+ { ts: '2026-04-11T12:34:59.000Z', text: 'Step 2 done. Starting step 3.' },
110
+ ]);
111
+ });
112
+ it('emits separate Gemini peek messages when a boundary separates logical messages', () => {
113
+ const extractor = new PeekMessageExtractor('gemini');
114
+ const output = [
115
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Starting step ","delta":true}',
116
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"1.","delta":true}',
117
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.822Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
118
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
119
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final ","delta":true}',
120
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"answer.","delta":true}',
121
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
122
+ ].join('\n') + '\n';
123
+ expect(extractor.push(output, ts)).toEqual([
124
+ { ts, text: 'Starting step 1.' },
125
+ { ts, text: 'Final answer.' },
126
+ ]);
127
+ expect(extractor.flush(ts)).toEqual([]);
128
+ });
129
+ it('does not emit Gemini user, tool, tool result, stats, or result response text', () => {
130
+ const extractor = new PeekMessageExtractor('gemini');
131
+ const output = [
132
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
133
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
134
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
135
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
136
+ ].join('\n') + '\n';
137
+ expect(extractor.push(output, ts)).toEqual([]);
138
+ expect(extractor.flush(ts)).toEqual([]);
139
+ });
140
+ it('denies unsupported agents and invalid shapes by default', () => {
141
+ const extractor = new PeekMessageExtractor('forge');
142
+ const output = [
143
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"not supported here"}]}}',
144
+ '{"type":"item.completed","item":{"type":"agent_message","text":"not supported here"}}',
145
+ '{"type":"text","part":{"type":"text","text":"not supported here"}}',
146
+ '{"type":"message","role":"assistant","content":"not supported here"}',
147
+ 'plain stdout',
148
+ ].join('\n') + '\n';
149
+ expect(extractor.push(output, ts)).toEqual([]);
150
+ });
151
+ it('extracts only OpenCode natural-language text events', () => {
152
+ const extractor = new PeekMessageExtractor('opencode');
153
+ const output = [
154
+ '{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}',
155
+ '{"type":"tool_use","timestamp":1775918783606,"sessionID":"ses-1","part":{"type":"tool","state":{"output":"secret command output"},"metadata":{"output":"secret metadata output"}}}',
156
+ '{"type":"text","timestamp":1775918783607,"sessionID":"ses-1","part":{"type":"tool","text":"wrong part type"}}',
157
+ ].join('\n') + '\n';
158
+ expect(extractor.push(output, ts)).toEqual([
159
+ { ts, text: 'OpenCode visible text' },
160
+ ]);
161
+ });
162
+ it('can flush a complete JSON event without a trailing newline', () => {
163
+ const extractor = new PeekMessageExtractor('codex');
164
+ expect(extractor.push('{"type":"item.completed","item":{"type":"agent_message","text":"pending"}}', ts)).toEqual([]);
165
+ expect(extractor.flush(ts)).toEqual([{ ts, text: 'pending' }]);
166
+ });
167
+ });
168
+ describe('parseGeminiOutput', () => {
169
+ it('should parse legacy final JSON output', () => {
170
+ const output = JSON.stringify({
171
+ session_id: 'gemini-session-json',
172
+ response: 'Legacy Gemini final response',
173
+ stats: {
174
+ total_tokens: 123,
175
+ },
176
+ });
177
+ expect(parseGeminiOutput(output)).toEqual({
178
+ session_id: 'gemini-session-json',
179
+ response: 'Legacy Gemini final response',
180
+ stats: {
181
+ total_tokens: 123,
182
+ },
183
+ });
184
+ });
185
+ it('should normalize a single-line Gemini assistant stream event', () => {
186
+ const output = '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Only answer","delta":true}';
187
+ const result = parseGeminiOutput(output);
188
+ expect(result).toMatchObject({
189
+ message: 'Only answer',
190
+ session_id: null,
191
+ });
192
+ expect(result).not.toHaveProperty('type');
193
+ expect(result).not.toHaveProperty('content');
194
+ });
195
+ it('should parse Gemini stream-json NDJSON output', () => {
196
+ const output = [
197
+ '{"type":"init","timestamp":"2026-04-11T14:44:42.293Z","session_id":"gemini-session-stream","model":"gemini-3.1-pro-preview"}',
198
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
199
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"First logical assistant response.","delta":true}',
200
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"echo hidden"}}',
201
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","tool_id":"tool-1","status":"success","output":"hidden command output"}',
202
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final assistant ","delta":true}',
203
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"response.","delta":true}',
204
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Result response is not the parsed message","stats":{"total_tokens":21999}}',
205
+ ].join('\n') + '\n';
206
+ expect(parseGeminiOutput(output)).toEqual({
207
+ message: 'Final assistant response.',
208
+ session_id: 'gemini-session-stream',
209
+ stats: {
210
+ total_tokens: 21999,
211
+ },
212
+ tools: [
213
+ {
214
+ tool: 'run_shell_command',
215
+ input: { command: 'echo hidden' },
216
+ output: 'hidden command output',
217
+ status: 'success',
218
+ },
219
+ ],
220
+ });
221
+ });
222
+ });
61
223
  describe('parseClaudeOutput', () => {
62
224
  it('should parse legacy JSON output', () => {
63
225
  const output = JSON.stringify({
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { appendPeekMessages, validatePeekPids, validatePeekTimeSec } from '../peek.js';
3
+ describe('peek helpers', () => {
4
+ it('dedupes pids while preserving first occurrence order', () => {
5
+ expect(validatePeekPids([3, 1, 3, 2, 1])).toEqual([3, 1, 2]);
6
+ });
7
+ it('validates pid and time limits', () => {
8
+ expect(() => validatePeekPids([])).toThrow(/1..32/);
9
+ expect(() => validatePeekPids([1.5])).toThrow(/positive safe integers/);
10
+ expect(() => validatePeekPids([Number.MAX_SAFE_INTEGER + 1])).toThrow(/positive safe integers/);
11
+ expect(validatePeekTimeSec(undefined)).toBe(10);
12
+ expect(validatePeekTimeSec(60)).toBe(60);
13
+ expect(() => validatePeekTimeSec(0)).toThrow(/positive integer/);
14
+ expect(() => validatePeekTimeSec(1.5)).toThrow(/positive integer/);
15
+ expect(() => validatePeekTimeSec(61)).toThrow(/positive integer/);
16
+ });
17
+ it('keeps the first 50 messages and marks truncation when later messages are dropped', () => {
18
+ const process = {
19
+ pid: 123,
20
+ agent: 'codex',
21
+ status: 'running',
22
+ messages: [],
23
+ truncated: false,
24
+ error: null,
25
+ };
26
+ appendPeekMessages(process, Array.from({ length: 55 }, (_, index) => ({
27
+ ts: '2026-04-11T12:34:56.789Z',
28
+ text: `message ${index}`,
29
+ })));
30
+ expect(process.messages).toHaveLength(50);
31
+ expect(process.messages[0].text).toBe('message 0');
32
+ expect(process.messages[49].text).toBe('message 49');
33
+ expect(process.truncated).toBe(true);
34
+ });
35
+ });