ai-cli-mcp 2.18.0 → 2.19.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 +7 -0
- package/README.ja.md +3 -3
- package/README.md +3 -3
- package/dist/__tests__/parsers.test.js +146 -0
- package/dist/__tests__/process-management.test.js +78 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/mcp.js +1 -1
- package/dist/cli-process-service.js +5 -4
- package/dist/parsers.js +103 -6
- package/dist/process-service.js +5 -4
- package/package.json +1 -1
- package/src/__tests__/parsers.test.ts +166 -0
- package/src/__tests__/process-management.test.ts +86 -0
- package/src/app/cli.ts +1 -1
- package/src/app/mcp.ts +1 -1
- package/src/cli-process-service.ts +5 -4
- package/src/parsers.ts +131 -6
- package/src/process-service.ts +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [2.19.0](https://github.com/mkXultra/ai-cli-mcp/compare/v2.18.0...v2.19.0) (2026-04-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* peekコマンドにForgeエージェントのベストエフォートサポートを追加 ([7c01958](https://github.com/mkXultra/ai-cli-mcp/commit/7c01958b0c9a8133da07c556b303481abd511b6b))
|
|
7
|
+
|
|
1
8
|
# [2.18.0](https://github.com/mkXultra/ai-cli-mcp/compare/v2.17.0...v2.18.0) (2026-04-12)
|
|
2
9
|
|
|
3
10
|
|
package/README.ja.md
CHANGED
|
@@ -297,9 +297,9 @@ ai-cli peek 123 --time 10 --include-tool-calls
|
|
|
297
297
|
- `peek_started_at` と `events[].ts` は、ai-cli-mcp サーバー側の UTC RFC3339 タイムスタンプです。`peek_started_at` は検証とリスナー登録後に観測ウィンドウが始まった時刻、`events[].ts` は ai-cli-mcp がイベントを観測して受理した時刻です。
|
|
298
298
|
- 観測ウィンドウは `peek_time_sec` が経過するか、対象プロセスがすべて終端状態になった時点で終了します。
|
|
299
299
|
- 観測開始前のイベントは返しません。同じPIDへの同時 `peek` は可能で、それぞれ独立した観測ウィンドウを持つため、イベントが重複して返ることがあります。
|
|
300
|
-
- メッセージイベントは、Codex の `agent_message` text、Claude assistant の text content、OpenCode の `type: "text"` かつ `part.type` が `"text"` のイベント、Gemini stream-json の `role` が `"assistant"` の `message`
|
|
301
|
-
- tool call を含める場合、Codex の command/MCP call、Claude の tool use/result、Gemini の tool use/result、OpenCode の完了済み tool use event を正規化した `tool_call` イベントとして返します。tool summary は tool 名と入力メタデータだけから作る短い1行文字列です。raw `stdout` / `stderr`、raw JSONL、tool result output、コマンド出力、`result.response`、stats、token usage、verbose メタデータは除外します。
|
|
302
|
-
-
|
|
300
|
+
- メッセージイベントは、Codex の `agent_message` text、Claude assistant の text content、OpenCode の `type: "text"` かつ `part.type` が `"text"` のイベント、Gemini stream-json の `role` が `"assistant"` の `message` イベント、Forge の `Summary:` または `Completed successfully:` で始まる plain-text 行から best-effort に認識します。
|
|
301
|
+
- tool call を含める場合、Codex の command/MCP call、Claude の tool use/result、Gemini の tool use/result、OpenCode の完了済み tool use event、Forge の低精度な `Execute` / `Finished` marker を正規化した `tool_call` イベントとして返します。tool summary は tool 名と入力メタデータだけから作る短い1行文字列です。Forge のコマンド出力自体は tail せず、公開しません。raw `stdout` / `stderr`、raw JSONL、tool result output、コマンド出力、`result.response`、stats、token usage、verbose メタデータは除外します。
|
|
302
|
+
- 未知のイベント形状はデフォルトで拒否します。まだ明示対応されていない管理対象エージェントは、実際のプロセス状態を返しつつ、`events: []`、`truncated: false`、`error: null` にします。
|
|
303
303
|
- 各PIDごとに、観測ウィンドウ内で最初に観測された50件までを保持します。それ以降のイベントを捨てた場合は `truncated` が `true` になります。
|
|
304
304
|
- `status` は `running`、`completed`、`failed`、`not_found` のいずれかで、観測ウィンドウ終了時点の状態を表します。
|
|
305
305
|
- `agent` は `claude`、`codex`、`gemini`、`forge`、`opencode`、将来追加される追跡済みエージェント文字列、または `null` です。`null` はプロセスが見つからない、またはエージェント種別を判断できない場合を表します。
|
package/README.md
CHANGED
|
@@ -294,9 +294,9 @@ ai-cli peek 123 --time 10 --include-tool-calls
|
|
|
294
294
|
- `peek_started_at` and `events[].ts` are ai-cli-mcp server-side UTC RFC3339 timestamps. `peek_started_at` is when the observation window starts after validation and listener registration; `events[].ts` is when ai-cli-mcp observed and accepted the event.
|
|
295
295
|
- The window ends when `peek_time_sec` elapses or all target processes reach a terminal state, whichever comes first.
|
|
296
296
|
- Events 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 events.
|
|
297
|
-
- Message events are recognized from Codex `agent_message` text, Claude assistant text content, OpenCode `type: "text"` events where `part.type` is `"text"`,
|
|
298
|
-
- When tool calls are included, `tool_call` events are normalized for Codex command/MCP calls, Claude tool use/results, Gemini tool use/results,
|
|
299
|
-
- Unknown event shapes are denied by default. Managed agents without supported extraction
|
|
297
|
+
- Message events are recognized from Codex `agent_message` text, Claude assistant text content, OpenCode `type: "text"` events where `part.type` is `"text"`, Gemini stream-json `message` events where `role` is `"assistant"`, and best-effort Forge plain-text lines beginning with `Summary:` or `Completed successfully:`.
|
|
298
|
+
- When tool calls are included, `tool_call` events are normalized for Codex command/MCP calls, Claude tool use/results, Gemini tool use/results, OpenCode completed tool use events, and low-precision Forge `Execute`/`Finished` markers. Tool summaries are bounded one-line strings derived from tool names and input metadata only. Forge command output itself is not tailed or exposed. Raw stdout/stderr, raw JSONL, tool result output, command output, `result.response`, stats, token usage, and verbose metadata are excluded.
|
|
299
|
+
- Unknown event shapes are denied by default. Managed agents without supported extraction return their real process status with `events: []`, `truncated: false`, and `error: null`.
|
|
300
300
|
- Each PID keeps the first 50 events observed in the window. If later events are dropped, `truncated` is `true`.
|
|
301
301
|
- `status` is one of `running`, `completed`, `failed`, or `not_found`, and reflects state when the observation window closes.
|
|
302
302
|
- `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.
|
|
@@ -307,6 +307,152 @@ describe('PeekEventExtractor', () => {
|
|
|
307
307
|
},
|
|
308
308
|
]);
|
|
309
309
|
});
|
|
310
|
+
it('emits Forge message events from Summary and Completed successfully prefixes', () => {
|
|
311
|
+
const extractor = new PeekEventExtractor('forge');
|
|
312
|
+
const output = [
|
|
313
|
+
'Summary: Forge finished the task',
|
|
314
|
+
'Completed successfully: Built the project',
|
|
315
|
+
'Summary: ',
|
|
316
|
+
].join('\n') + '\n';
|
|
317
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
318
|
+
{ kind: 'message', ts, text: 'Forge finished the task' },
|
|
319
|
+
{ kind: 'message', ts, text: 'Built the project' },
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
it('preserves long Forge Summary message text without truncation', () => {
|
|
323
|
+
const extractor = new PeekEventExtractor('forge');
|
|
324
|
+
const longText = 'x'.repeat(260);
|
|
325
|
+
expect(extractor.push(`Summary: ${longText}\n`, ts)).toEqual([
|
|
326
|
+
{ kind: 'message', ts, text: longText },
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
it('emits Forge Execute tool_call starts when include_tool_calls is true', () => {
|
|
330
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
331
|
+
expect(extractor.push("● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n", ts)).toEqual([
|
|
332
|
+
{
|
|
333
|
+
kind: 'tool_call',
|
|
334
|
+
ts,
|
|
335
|
+
phase: 'started',
|
|
336
|
+
id: 'forge_0',
|
|
337
|
+
tool: '/bin/zsh',
|
|
338
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
339
|
+
},
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
it('falls back to shell for Forge Execute labels with spaces', () => {
|
|
343
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
344
|
+
expect(extractor.push("● [11:28:40] Execute [local shell] /bin/sh -c 'echo hi'\n", ts)).toEqual([
|
|
345
|
+
{
|
|
346
|
+
kind: 'tool_call',
|
|
347
|
+
ts,
|
|
348
|
+
phase: 'started',
|
|
349
|
+
id: 'forge_0',
|
|
350
|
+
tool: 'shell',
|
|
351
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
352
|
+
},
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
it('suppresses Forge tool_call events when include_tool_calls is false but keeps messages', () => {
|
|
356
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: false });
|
|
357
|
+
const output = [
|
|
358
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'",
|
|
359
|
+
'Summary: done',
|
|
360
|
+
].join('\n') + '\n';
|
|
361
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
362
|
+
{ kind: 'message', ts, text: 'done' },
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
it('completes a pending Forge tool_call only on anchored Finished markers', () => {
|
|
366
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
367
|
+
const output = [
|
|
368
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'",
|
|
369
|
+
'This line says Finished but is not a Forge marker',
|
|
370
|
+
'● [11:28:41] Finished abc123',
|
|
371
|
+
].join('\n') + '\n';
|
|
372
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
373
|
+
{
|
|
374
|
+
kind: 'tool_call',
|
|
375
|
+
ts,
|
|
376
|
+
phase: 'started',
|
|
377
|
+
id: 'forge_0',
|
|
378
|
+
tool: '/bin/zsh',
|
|
379
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
kind: 'tool_call',
|
|
383
|
+
ts,
|
|
384
|
+
phase: 'completed',
|
|
385
|
+
id: 'forge_0',
|
|
386
|
+
tool: '/bin/zsh',
|
|
387
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
388
|
+
status: 'unknown',
|
|
389
|
+
},
|
|
390
|
+
]);
|
|
391
|
+
});
|
|
392
|
+
it('completes a pending Forge tool_call before starting a consecutive Execute marker', () => {
|
|
393
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
394
|
+
const output = [
|
|
395
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo one'",
|
|
396
|
+
"● [11:28:41] Execute [/bin/zsh] /bin/sh -c 'echo two'",
|
|
397
|
+
].join('\n') + '\n';
|
|
398
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
399
|
+
{
|
|
400
|
+
kind: 'tool_call',
|
|
401
|
+
ts,
|
|
402
|
+
phase: 'started',
|
|
403
|
+
id: 'forge_0',
|
|
404
|
+
tool: '/bin/zsh',
|
|
405
|
+
summary: "/bin/sh -c 'echo one'",
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
kind: 'tool_call',
|
|
409
|
+
ts,
|
|
410
|
+
phase: 'completed',
|
|
411
|
+
id: 'forge_0',
|
|
412
|
+
tool: '/bin/zsh',
|
|
413
|
+
summary: "/bin/sh -c 'echo one'",
|
|
414
|
+
status: 'unknown',
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
kind: 'tool_call',
|
|
418
|
+
ts,
|
|
419
|
+
phase: 'started',
|
|
420
|
+
id: 'forge_1',
|
|
421
|
+
tool: '/bin/zsh',
|
|
422
|
+
summary: "/bin/sh -c 'echo two'",
|
|
423
|
+
},
|
|
424
|
+
]);
|
|
425
|
+
});
|
|
426
|
+
it('does not synthesize Forge completion on non-terminal flush', () => {
|
|
427
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
428
|
+
expect(extractor.push("● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n", ts)).toHaveLength(1);
|
|
429
|
+
expect(extractor.flush(ts, { terminal: false })).toEqual([]);
|
|
430
|
+
});
|
|
431
|
+
it('synthesizes Forge completion with unknown status on terminal flush', () => {
|
|
432
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
433
|
+
expect(extractor.push("● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n", ts)).toHaveLength(1);
|
|
434
|
+
expect(extractor.flush('2026-04-12T02:10:05.000Z', { terminal: true })).toEqual([
|
|
435
|
+
{
|
|
436
|
+
kind: 'tool_call',
|
|
437
|
+
ts: '2026-04-12T02:10:05.000Z',
|
|
438
|
+
phase: 'completed',
|
|
439
|
+
id: 'forge_0',
|
|
440
|
+
tool: '/bin/zsh',
|
|
441
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
442
|
+
status: 'unknown',
|
|
443
|
+
},
|
|
444
|
+
]);
|
|
445
|
+
});
|
|
446
|
+
it('treats Forge stderr as a no-op source', () => {
|
|
447
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true, source: 'stderr' });
|
|
448
|
+
const output = [
|
|
449
|
+
'Summary: hidden',
|
|
450
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hidden'",
|
|
451
|
+
'● [11:28:41] Finished',
|
|
452
|
+
].join('\n') + '\n';
|
|
453
|
+
expect(extractor.push(output, ts)).toEqual([]);
|
|
454
|
+
expect(extractor.flush(ts, { terminal: true })).toEqual([]);
|
|
455
|
+
});
|
|
310
456
|
});
|
|
311
457
|
describe('parseGeminiOutput', () => {
|
|
312
458
|
it('should parse legacy final JSON output', () => {
|
|
@@ -334,6 +334,84 @@ describe('Process Management Tests', () => {
|
|
|
334
334
|
});
|
|
335
335
|
expect(JSON.stringify(response)).not.toContain('secret result');
|
|
336
336
|
});
|
|
337
|
+
it('should peek Forge plain-text messages and low-precision tool calls without raw command output', async () => {
|
|
338
|
+
const { handlers } = await setupServer();
|
|
339
|
+
const mockProcess = new EventEmitter();
|
|
340
|
+
mockProcess.pid = 12349;
|
|
341
|
+
mockProcess.stdout = new EventEmitter();
|
|
342
|
+
mockProcess.stderr = new EventEmitter();
|
|
343
|
+
mockProcess.kill = vi.fn();
|
|
344
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
345
|
+
const callToolHandler = handlers.get('callTool');
|
|
346
|
+
await callToolHandler({
|
|
347
|
+
params: {
|
|
348
|
+
name: 'run',
|
|
349
|
+
arguments: {
|
|
350
|
+
prompt: 'forge peek prompt',
|
|
351
|
+
workFolder: '/tmp',
|
|
352
|
+
model: 'forge',
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
const peekPromise = callToolHandler({
|
|
357
|
+
params: {
|
|
358
|
+
name: 'peek',
|
|
359
|
+
arguments: {
|
|
360
|
+
pids: [12349],
|
|
361
|
+
peek_time_sec: 1,
|
|
362
|
+
include_tool_calls: true,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
setTimeout(() => {
|
|
367
|
+
mockProcess.stdout.emit('data', 'Summary: Forge started\n');
|
|
368
|
+
mockProcess.stdout.emit('data', "● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n");
|
|
369
|
+
mockProcess.stdout.emit('data', 'secret child output\n');
|
|
370
|
+
mockProcess.stderr.emit('data', 'Summary: stderr should be ignored\n');
|
|
371
|
+
mockProcess.stdout.emit('data', '● [11:28:41] Finished abc123\n');
|
|
372
|
+
mockProcess.stdout.emit('data', 'Completed successfully: Forge done\n');
|
|
373
|
+
mockProcess.emit('close', 0);
|
|
374
|
+
}, 10);
|
|
375
|
+
const result = await peekPromise;
|
|
376
|
+
const response = JSON.parse(result.content[0].text);
|
|
377
|
+
expect(response.processes).toHaveLength(1);
|
|
378
|
+
expect(response.processes[0]).toMatchObject({
|
|
379
|
+
pid: 12349,
|
|
380
|
+
agent: 'forge',
|
|
381
|
+
status: 'completed',
|
|
382
|
+
events: [
|
|
383
|
+
{
|
|
384
|
+
kind: 'message',
|
|
385
|
+
ts: expect.any(String),
|
|
386
|
+
text: 'Forge started',
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
kind: 'tool_call',
|
|
390
|
+
phase: 'started',
|
|
391
|
+
id: 'forge_0',
|
|
392
|
+
tool: '/bin/zsh',
|
|
393
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
kind: 'tool_call',
|
|
397
|
+
phase: 'completed',
|
|
398
|
+
id: 'forge_0',
|
|
399
|
+
tool: '/bin/zsh',
|
|
400
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
401
|
+
status: 'unknown',
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
kind: 'message',
|
|
405
|
+
ts: expect.any(String),
|
|
406
|
+
text: 'Forge done',
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
truncated: false,
|
|
410
|
+
error: null,
|
|
411
|
+
});
|
|
412
|
+
expect(JSON.stringify(response)).not.toContain('secret child output');
|
|
413
|
+
expect(JSON.stringify(response)).not.toContain('stderr should be ignored');
|
|
414
|
+
});
|
|
337
415
|
it('should handle process with model parameter', async () => {
|
|
338
416
|
const { handlers } = await setupServer();
|
|
339
417
|
const mockProcess = new EventEmitter();
|
package/dist/app/cli.js
CHANGED
|
@@ -58,7 +58,7 @@ Options:
|
|
|
58
58
|
export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
|
|
59
59
|
|
|
60
60
|
Observe new natural-language agent messages, and optionally tool calls, for a short one-shot window.
|
|
61
|
-
In v1, message extraction is supported for Codex, Claude, OpenCode, and
|
|
61
|
+
In v1, message extraction is supported for Codex, Claude, OpenCode, Gemini, and best-effort Forge Summary/Completed successfully lines. Forge tool calls are low-precision Execute/Finished markers and never include command output.
|
|
62
62
|
This is not a history API, gapless streaming, or stdout/stderr tailing. No --follow mode is available in v1.
|
|
63
63
|
|
|
64
64
|
Options:
|
package/dist/app/mcp.js
CHANGED
|
@@ -211,7 +211,7 @@ ${getSupportedModelsDescription()}
|
|
|
211
211
|
},
|
|
212
212
|
{
|
|
213
213
|
name: 'peek',
|
|
214
|
-
description: 'One-shot short observation window for running child agents. Returns only natural-language message events, and optionally normalized tool_call events, observed during this call; not a history API, not gapless streaming, and not stdout/stderr tailing. In v1, message extraction is supported for Codex, Claude, OpenCode, and
|
|
214
|
+
description: 'One-shot short observation window for running child agents. Returns only natural-language message events, and optionally normalized tool_call events, observed during this call; not a history API, not gapless streaming, and not stdout/stderr tailing. In v1, message extraction is supported for Codex, Claude, OpenCode, Gemini, and best-effort Forge Summary/Completed successfully lines. Forge tool calls are low-precision Execute/Finished markers and never include command output. Tool calls exclude raw tool output.',
|
|
215
215
|
inputSchema: {
|
|
216
216
|
type: 'object',
|
|
217
217
|
properties: {
|
|
@@ -201,8 +201,8 @@ export class CliProcessService {
|
|
|
201
201
|
observers.push({
|
|
202
202
|
process,
|
|
203
203
|
result,
|
|
204
|
-
stdoutExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls }),
|
|
205
|
-
stderrExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls }),
|
|
204
|
+
stdoutExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls, source: 'stdout' }),
|
|
205
|
+
stderrExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls, source: 'stderr' }),
|
|
206
206
|
stdoutOffset: this.fileSizeSafe(process.stdoutPath),
|
|
207
207
|
stderrOffset: this.fileSizeSafe(process.stderrPath),
|
|
208
208
|
});
|
|
@@ -239,8 +239,9 @@ export class CliProcessService {
|
|
|
239
239
|
for (const observer of observers) {
|
|
240
240
|
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
241
241
|
observer.result.status = observer.process.status;
|
|
242
|
-
|
|
243
|
-
appendPeekEvents(observer.result, observer.
|
|
242
|
+
const terminal = observer.process.status !== 'running';
|
|
243
|
+
appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs, { terminal }));
|
|
244
|
+
appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs, { terminal }));
|
|
244
245
|
}
|
|
245
246
|
return {
|
|
246
247
|
peek_started_at: startedAt.toISOString(),
|
package/dist/parsers.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { debugLog } from './cli-utils.js';
|
|
2
2
|
const PEEK_TOOL_SUMMARY_MAX_LENGTH = 200;
|
|
3
|
+
const FORGE_EXECUTE_PATTERN = /^● \[[^\]]+\] Execute \[([^\]]*)\]\s+(.+)$/;
|
|
4
|
+
const FORGE_FINISHED_PATTERN = /^● \[[^\]]+\] Finished(?:\s+\S+)?\s*$/;
|
|
3
5
|
function isGeminiAssistantMessageEvent(parsed) {
|
|
4
6
|
return parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string';
|
|
5
7
|
}
|
|
@@ -253,12 +255,19 @@ export class PeekEventExtractor {
|
|
|
253
255
|
pending = '';
|
|
254
256
|
geminiAssistantBuffer = '';
|
|
255
257
|
includeToolCalls;
|
|
258
|
+
source;
|
|
256
259
|
toolMemory = new Map();
|
|
260
|
+
forgePendingTool = null;
|
|
261
|
+
forgeToolSequence = 0;
|
|
257
262
|
constructor(agent, options = {}) {
|
|
258
263
|
this.agent = agent;
|
|
259
264
|
this.includeToolCalls = options.includeToolCalls === true;
|
|
265
|
+
this.source = options.source || 'stdout';
|
|
260
266
|
}
|
|
261
267
|
push(chunk, observedAt = new Date().toISOString()) {
|
|
268
|
+
if (this.agent === 'forge' && this.source === 'stderr') {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
262
271
|
if (!chunk) {
|
|
263
272
|
return [];
|
|
264
273
|
}
|
|
@@ -266,17 +275,27 @@ export class PeekEventExtractor {
|
|
|
266
275
|
this.pending = lines.pop() || '';
|
|
267
276
|
return this.extractLines(lines, observedAt);
|
|
268
277
|
}
|
|
269
|
-
flush(observedAt = new Date().toISOString()) {
|
|
278
|
+
flush(observedAt = new Date().toISOString(), options = {}) {
|
|
279
|
+
if (this.agent === 'forge' && this.source === 'stderr') {
|
|
280
|
+
this.pending = '';
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
270
283
|
const events = [];
|
|
271
284
|
if (this.pending) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
285
|
+
if (this.agent !== 'forge' || options.terminal === true) {
|
|
286
|
+
const line = this.pending;
|
|
287
|
+
this.pending = '';
|
|
288
|
+
events.push(...this.extractLines([line], observedAt));
|
|
289
|
+
}
|
|
275
290
|
}
|
|
276
291
|
events.push(...this.flushGeminiAssistantBuffer(observedAt));
|
|
292
|
+
events.push(...this.flushForgePendingTool(observedAt, options.terminal === true));
|
|
277
293
|
return events;
|
|
278
294
|
}
|
|
279
295
|
extractLines(lines, observedAt) {
|
|
296
|
+
if (this.agent === 'forge') {
|
|
297
|
+
return this.extractForgeLines(lines, observedAt);
|
|
298
|
+
}
|
|
280
299
|
const events = [];
|
|
281
300
|
for (const line of lines) {
|
|
282
301
|
if (!line.trim()) {
|
|
@@ -292,6 +311,58 @@ export class PeekEventExtractor {
|
|
|
292
311
|
}
|
|
293
312
|
return events;
|
|
294
313
|
}
|
|
314
|
+
extractForgeLines(lines, observedAt) {
|
|
315
|
+
const events = [];
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
if (!line.trim()) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const summary = this.extractForgeMessage(line, 'Summary:');
|
|
321
|
+
if (summary !== null) {
|
|
322
|
+
events.push({ kind: 'message', ts: observedAt, text: summary });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const completed = this.extractForgeMessage(line, 'Completed successfully:');
|
|
326
|
+
if (completed !== null) {
|
|
327
|
+
events.push({ kind: 'message', ts: observedAt, text: completed });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (this.includeToolCalls) {
|
|
331
|
+
const executeMatch = line.match(FORGE_EXECUTE_PATTERN);
|
|
332
|
+
if (executeMatch) {
|
|
333
|
+
events.push(...this.completeForgePendingTool(observedAt));
|
|
334
|
+
const [, rawTool, rawSummary] = executeMatch;
|
|
335
|
+
const tool = rawTool.trim() && !/\s/.test(rawTool.trim()) ? rawTool.trim() : 'shell';
|
|
336
|
+
const event = createToolCallEvent({
|
|
337
|
+
ts: observedAt,
|
|
338
|
+
phase: 'started',
|
|
339
|
+
id: `forge_${this.forgeToolSequence++}`,
|
|
340
|
+
tool,
|
|
341
|
+
command: rawSummary,
|
|
342
|
+
});
|
|
343
|
+
this.forgePendingTool = {
|
|
344
|
+
id: event.id,
|
|
345
|
+
tool: event.tool,
|
|
346
|
+
summary: event.summary,
|
|
347
|
+
summary_truncated: event.summary_truncated,
|
|
348
|
+
};
|
|
349
|
+
events.push(event);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (FORGE_FINISHED_PATTERN.test(line)) {
|
|
353
|
+
events.push(...this.completeForgePendingTool(observedAt));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return events;
|
|
358
|
+
}
|
|
359
|
+
extractForgeMessage(line, prefix) {
|
|
360
|
+
if (!line.startsWith(prefix)) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
const text = line.slice(prefix.length).trim();
|
|
364
|
+
return text || null;
|
|
365
|
+
}
|
|
295
366
|
extractParsedEvent(parsed, observedAt) {
|
|
296
367
|
if (this.agent === 'gemini') {
|
|
297
368
|
const events = this.extractGeminiParsedEvent(parsed, observedAt);
|
|
@@ -339,6 +410,32 @@ export class PeekEventExtractor {
|
|
|
339
410
|
}
|
|
340
411
|
return [{ kind: 'message', ts: observedAt, text }];
|
|
341
412
|
}
|
|
413
|
+
completeForgePendingTool(observedAt) {
|
|
414
|
+
if (!this.forgePendingTool) {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
const pending = this.forgePendingTool;
|
|
418
|
+
this.forgePendingTool = null;
|
|
419
|
+
const event = createToolCallEvent({
|
|
420
|
+
ts: observedAt,
|
|
421
|
+
phase: 'completed',
|
|
422
|
+
id: pending.id,
|
|
423
|
+
tool: pending.tool,
|
|
424
|
+
status: 'unknown',
|
|
425
|
+
defaultStatus: 'unknown',
|
|
426
|
+
});
|
|
427
|
+
event.summary = pending.summary;
|
|
428
|
+
if (pending.summary_truncated) {
|
|
429
|
+
event.summary_truncated = true;
|
|
430
|
+
}
|
|
431
|
+
return [event];
|
|
432
|
+
}
|
|
433
|
+
flushForgePendingTool(observedAt, terminal) {
|
|
434
|
+
if (this.agent !== 'forge' || !terminal) {
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
return this.completeForgePendingTool(observedAt);
|
|
438
|
+
}
|
|
342
439
|
}
|
|
343
440
|
export class PeekMessageExtractor {
|
|
344
441
|
extractor;
|
|
@@ -348,8 +445,8 @@ export class PeekMessageExtractor {
|
|
|
348
445
|
push(chunk, observedAt = new Date().toISOString()) {
|
|
349
446
|
return this.toMessages(this.extractor.push(chunk, observedAt));
|
|
350
447
|
}
|
|
351
|
-
flush(observedAt = new Date().toISOString()) {
|
|
352
|
-
return this.toMessages(this.extractor.flush(observedAt));
|
|
448
|
+
flush(observedAt = new Date().toISOString(), options = {}) {
|
|
449
|
+
return this.toMessages(this.extractor.flush(observedAt, options));
|
|
353
450
|
}
|
|
354
451
|
toMessages(events) {
|
|
355
452
|
return events
|
package/dist/process-service.js
CHANGED
|
@@ -176,8 +176,8 @@ export class ProcessService {
|
|
|
176
176
|
error: null,
|
|
177
177
|
};
|
|
178
178
|
processes.push(result);
|
|
179
|
-
const stdoutExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls });
|
|
180
|
-
const stderrExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls });
|
|
179
|
+
const stdoutExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls, source: 'stdout' });
|
|
180
|
+
const stderrExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls, source: 'stderr' });
|
|
181
181
|
const onStdout = (data) => {
|
|
182
182
|
appendPeekEvents(result, stdoutExtractor.push(data.toString(), new Date().toISOString()));
|
|
183
183
|
};
|
|
@@ -210,8 +210,9 @@ export class ProcessService {
|
|
|
210
210
|
for (const observer of observers) {
|
|
211
211
|
observer.entry.process.stdout?.off('data', observer.onStdout);
|
|
212
212
|
observer.entry.process.stderr?.off('data', observer.onStderr);
|
|
213
|
-
|
|
214
|
-
appendPeekEvents(observer.result, observer.
|
|
213
|
+
const terminal = observer.entry.status !== 'running';
|
|
214
|
+
appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs, { terminal }));
|
|
215
|
+
appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs, { terminal }));
|
|
215
216
|
observer.result.status = observer.entry.status;
|
|
216
217
|
}
|
|
217
218
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-cli-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"mcpName": "io.github.mkXultra/ai-cli-mcp",
|
|
5
5
|
"description": "MCP server for AI CLI tools (Claude, Codex, Gemini, Forge, and OpenCode) with background process management",
|
|
6
6
|
"author": "mkXultra",
|
|
@@ -342,6 +342,172 @@ describe('PeekEventExtractor', () => {
|
|
|
342
342
|
},
|
|
343
343
|
]);
|
|
344
344
|
});
|
|
345
|
+
|
|
346
|
+
it('emits Forge message events from Summary and Completed successfully prefixes', () => {
|
|
347
|
+
const extractor = new PeekEventExtractor('forge');
|
|
348
|
+
const output = [
|
|
349
|
+
'Summary: Forge finished the task',
|
|
350
|
+
'Completed successfully: Built the project',
|
|
351
|
+
'Summary: ',
|
|
352
|
+
].join('\n') + '\n';
|
|
353
|
+
|
|
354
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
355
|
+
{ kind: 'message', ts, text: 'Forge finished the task' },
|
|
356
|
+
{ kind: 'message', ts, text: 'Built the project' },
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('preserves long Forge Summary message text without truncation', () => {
|
|
361
|
+
const extractor = new PeekEventExtractor('forge');
|
|
362
|
+
const longText = 'x'.repeat(260);
|
|
363
|
+
|
|
364
|
+
expect(extractor.push(`Summary: ${longText}\n`, ts)).toEqual([
|
|
365
|
+
{ kind: 'message', ts, text: longText },
|
|
366
|
+
]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('emits Forge Execute tool_call starts when include_tool_calls is true', () => {
|
|
370
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
371
|
+
|
|
372
|
+
expect(extractor.push("● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n", ts)).toEqual([
|
|
373
|
+
{
|
|
374
|
+
kind: 'tool_call',
|
|
375
|
+
ts,
|
|
376
|
+
phase: 'started',
|
|
377
|
+
id: 'forge_0',
|
|
378
|
+
tool: '/bin/zsh',
|
|
379
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
380
|
+
},
|
|
381
|
+
]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('falls back to shell for Forge Execute labels with spaces', () => {
|
|
385
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
386
|
+
|
|
387
|
+
expect(extractor.push("● [11:28:40] Execute [local shell] /bin/sh -c 'echo hi'\n", ts)).toEqual([
|
|
388
|
+
{
|
|
389
|
+
kind: 'tool_call',
|
|
390
|
+
ts,
|
|
391
|
+
phase: 'started',
|
|
392
|
+
id: 'forge_0',
|
|
393
|
+
tool: 'shell',
|
|
394
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
395
|
+
},
|
|
396
|
+
]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('suppresses Forge tool_call events when include_tool_calls is false but keeps messages', () => {
|
|
400
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: false });
|
|
401
|
+
const output = [
|
|
402
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'",
|
|
403
|
+
'Summary: done',
|
|
404
|
+
].join('\n') + '\n';
|
|
405
|
+
|
|
406
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
407
|
+
{ kind: 'message', ts, text: 'done' },
|
|
408
|
+
]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('completes a pending Forge tool_call only on anchored Finished markers', () => {
|
|
412
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
413
|
+
const output = [
|
|
414
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'",
|
|
415
|
+
'This line says Finished but is not a Forge marker',
|
|
416
|
+
'● [11:28:41] Finished abc123',
|
|
417
|
+
].join('\n') + '\n';
|
|
418
|
+
|
|
419
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
420
|
+
{
|
|
421
|
+
kind: 'tool_call',
|
|
422
|
+
ts,
|
|
423
|
+
phase: 'started',
|
|
424
|
+
id: 'forge_0',
|
|
425
|
+
tool: '/bin/zsh',
|
|
426
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
kind: 'tool_call',
|
|
430
|
+
ts,
|
|
431
|
+
phase: 'completed',
|
|
432
|
+
id: 'forge_0',
|
|
433
|
+
tool: '/bin/zsh',
|
|
434
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
435
|
+
status: 'unknown',
|
|
436
|
+
},
|
|
437
|
+
]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('completes a pending Forge tool_call before starting a consecutive Execute marker', () => {
|
|
441
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
442
|
+
const output = [
|
|
443
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo one'",
|
|
444
|
+
"● [11:28:41] Execute [/bin/zsh] /bin/sh -c 'echo two'",
|
|
445
|
+
].join('\n') + '\n';
|
|
446
|
+
|
|
447
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
448
|
+
{
|
|
449
|
+
kind: 'tool_call',
|
|
450
|
+
ts,
|
|
451
|
+
phase: 'started',
|
|
452
|
+
id: 'forge_0',
|
|
453
|
+
tool: '/bin/zsh',
|
|
454
|
+
summary: "/bin/sh -c 'echo one'",
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
kind: 'tool_call',
|
|
458
|
+
ts,
|
|
459
|
+
phase: 'completed',
|
|
460
|
+
id: 'forge_0',
|
|
461
|
+
tool: '/bin/zsh',
|
|
462
|
+
summary: "/bin/sh -c 'echo one'",
|
|
463
|
+
status: 'unknown',
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
kind: 'tool_call',
|
|
467
|
+
ts,
|
|
468
|
+
phase: 'started',
|
|
469
|
+
id: 'forge_1',
|
|
470
|
+
tool: '/bin/zsh',
|
|
471
|
+
summary: "/bin/sh -c 'echo two'",
|
|
472
|
+
},
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('does not synthesize Forge completion on non-terminal flush', () => {
|
|
477
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
478
|
+
|
|
479
|
+
expect(extractor.push("● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n", ts)).toHaveLength(1);
|
|
480
|
+
expect(extractor.flush(ts, { terminal: false })).toEqual([]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('synthesizes Forge completion with unknown status on terminal flush', () => {
|
|
484
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true });
|
|
485
|
+
|
|
486
|
+
expect(extractor.push("● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n", ts)).toHaveLength(1);
|
|
487
|
+
expect(extractor.flush('2026-04-12T02:10:05.000Z', { terminal: true })).toEqual([
|
|
488
|
+
{
|
|
489
|
+
kind: 'tool_call',
|
|
490
|
+
ts: '2026-04-12T02:10:05.000Z',
|
|
491
|
+
phase: 'completed',
|
|
492
|
+
id: 'forge_0',
|
|
493
|
+
tool: '/bin/zsh',
|
|
494
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
495
|
+
status: 'unknown',
|
|
496
|
+
},
|
|
497
|
+
]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('treats Forge stderr as a no-op source', () => {
|
|
501
|
+
const extractor = new PeekEventExtractor('forge', { includeToolCalls: true, source: 'stderr' });
|
|
502
|
+
const output = [
|
|
503
|
+
'Summary: hidden',
|
|
504
|
+
"● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hidden'",
|
|
505
|
+
'● [11:28:41] Finished',
|
|
506
|
+
].join('\n') + '\n';
|
|
507
|
+
|
|
508
|
+
expect(extractor.push(output, ts)).toEqual([]);
|
|
509
|
+
expect(extractor.flush(ts, { terminal: true })).toEqual([]);
|
|
510
|
+
});
|
|
345
511
|
});
|
|
346
512
|
|
|
347
513
|
describe('parseGeminiOutput', () => {
|
|
@@ -384,6 +384,92 @@ describe('Process Management Tests', () => {
|
|
|
384
384
|
expect(JSON.stringify(response)).not.toContain('secret result');
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
it('should peek Forge plain-text messages and low-precision tool calls without raw command output', async () => {
|
|
388
|
+
const { handlers } = await setupServer();
|
|
389
|
+
|
|
390
|
+
const mockProcess = new EventEmitter() as any;
|
|
391
|
+
mockProcess.pid = 12349;
|
|
392
|
+
mockProcess.stdout = new EventEmitter();
|
|
393
|
+
mockProcess.stderr = new EventEmitter();
|
|
394
|
+
mockProcess.kill = vi.fn();
|
|
395
|
+
|
|
396
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
397
|
+
|
|
398
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
399
|
+
await callToolHandler!({
|
|
400
|
+
params: {
|
|
401
|
+
name: 'run',
|
|
402
|
+
arguments: {
|
|
403
|
+
prompt: 'forge peek prompt',
|
|
404
|
+
workFolder: '/tmp',
|
|
405
|
+
model: 'forge',
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const peekPromise = callToolHandler!({
|
|
411
|
+
params: {
|
|
412
|
+
name: 'peek',
|
|
413
|
+
arguments: {
|
|
414
|
+
pids: [12349],
|
|
415
|
+
peek_time_sec: 1,
|
|
416
|
+
include_tool_calls: true,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
setTimeout(() => {
|
|
422
|
+
mockProcess.stdout.emit('data', 'Summary: Forge started\n');
|
|
423
|
+
mockProcess.stdout.emit('data', "● [11:28:40] Execute [/bin/zsh] /bin/sh -c 'echo hi'\n");
|
|
424
|
+
mockProcess.stdout.emit('data', 'secret child output\n');
|
|
425
|
+
mockProcess.stderr.emit('data', 'Summary: stderr should be ignored\n');
|
|
426
|
+
mockProcess.stdout.emit('data', '● [11:28:41] Finished abc123\n');
|
|
427
|
+
mockProcess.stdout.emit('data', 'Completed successfully: Forge done\n');
|
|
428
|
+
mockProcess.emit('close', 0);
|
|
429
|
+
}, 10);
|
|
430
|
+
|
|
431
|
+
const result = await peekPromise;
|
|
432
|
+
const response = JSON.parse(result.content[0].text);
|
|
433
|
+
|
|
434
|
+
expect(response.processes).toHaveLength(1);
|
|
435
|
+
expect(response.processes[0]).toMatchObject({
|
|
436
|
+
pid: 12349,
|
|
437
|
+
agent: 'forge',
|
|
438
|
+
status: 'completed',
|
|
439
|
+
events: [
|
|
440
|
+
{
|
|
441
|
+
kind: 'message',
|
|
442
|
+
ts: expect.any(String),
|
|
443
|
+
text: 'Forge started',
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
kind: 'tool_call',
|
|
447
|
+
phase: 'started',
|
|
448
|
+
id: 'forge_0',
|
|
449
|
+
tool: '/bin/zsh',
|
|
450
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
kind: 'tool_call',
|
|
454
|
+
phase: 'completed',
|
|
455
|
+
id: 'forge_0',
|
|
456
|
+
tool: '/bin/zsh',
|
|
457
|
+
summary: "/bin/sh -c 'echo hi'",
|
|
458
|
+
status: 'unknown',
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
kind: 'message',
|
|
462
|
+
ts: expect.any(String),
|
|
463
|
+
text: 'Forge done',
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
truncated: false,
|
|
467
|
+
error: null,
|
|
468
|
+
});
|
|
469
|
+
expect(JSON.stringify(response)).not.toContain('secret child output');
|
|
470
|
+
expect(JSON.stringify(response)).not.toContain('stderr should be ignored');
|
|
471
|
+
});
|
|
472
|
+
|
|
387
473
|
it('should handle process with model parameter', async () => {
|
|
388
474
|
const { handlers } = await setupServer();
|
|
389
475
|
|
package/src/app/cli.ts
CHANGED
|
@@ -63,7 +63,7 @@ Options:
|
|
|
63
63
|
export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
|
|
64
64
|
|
|
65
65
|
Observe new natural-language agent messages, and optionally tool calls, for a short one-shot window.
|
|
66
|
-
In v1, message extraction is supported for Codex, Claude, OpenCode, and
|
|
66
|
+
In v1, message extraction is supported for Codex, Claude, OpenCode, Gemini, and best-effort Forge Summary/Completed successfully lines. Forge tool calls are low-precision Execute/Finished markers and never include command output.
|
|
67
67
|
This is not a history API, gapless streaming, or stdout/stderr tailing. No --follow mode is available in v1.
|
|
68
68
|
|
|
69
69
|
Options:
|
package/src/app/mcp.ts
CHANGED
|
@@ -233,7 +233,7 @@ ${getSupportedModelsDescription()}
|
|
|
233
233
|
},
|
|
234
234
|
{
|
|
235
235
|
name: 'peek',
|
|
236
|
-
description: 'One-shot short observation window for running child agents. Returns only natural-language message events, and optionally normalized tool_call events, observed during this call; not a history API, not gapless streaming, and not stdout/stderr tailing. In v1, message extraction is supported for Codex, Claude, OpenCode, and
|
|
236
|
+
description: 'One-shot short observation window for running child agents. Returns only natural-language message events, and optionally normalized tool_call events, observed during this call; not a history API, not gapless streaming, and not stdout/stderr tailing. In v1, message extraction is supported for Codex, Claude, OpenCode, Gemini, and best-effort Forge Summary/Completed successfully lines. Forge tool calls are low-precision Execute/Finished markers and never include command output. Tool calls exclude raw tool output.',
|
|
237
237
|
inputSchema: {
|
|
238
238
|
type: 'object',
|
|
239
239
|
properties: {
|
|
@@ -289,8 +289,8 @@ export class CliProcessService {
|
|
|
289
289
|
observers.push({
|
|
290
290
|
process,
|
|
291
291
|
result,
|
|
292
|
-
stdoutExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls }),
|
|
293
|
-
stderrExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls }),
|
|
292
|
+
stdoutExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls, source: 'stdout' }),
|
|
293
|
+
stderrExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls, source: 'stderr' }),
|
|
294
294
|
stdoutOffset: this.fileSizeSafe(process.stdoutPath),
|
|
295
295
|
stderrOffset: this.fileSizeSafe(process.stderrPath),
|
|
296
296
|
});
|
|
@@ -335,8 +335,9 @@ export class CliProcessService {
|
|
|
335
335
|
for (const observer of observers) {
|
|
336
336
|
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
337
337
|
observer.result.status = observer.process.status;
|
|
338
|
-
|
|
339
|
-
appendPeekEvents(observer.result, observer.
|
|
338
|
+
const terminal = observer.process.status !== 'running';
|
|
339
|
+
appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs, { terminal }));
|
|
340
|
+
appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs, { terminal }));
|
|
340
341
|
}
|
|
341
342
|
|
|
342
343
|
return {
|
package/src/parsers.ts
CHANGED
|
@@ -29,6 +29,11 @@ type PeekAgent = 'claude' | 'codex' | string | null;
|
|
|
29
29
|
|
|
30
30
|
interface PeekEventExtractorOptions {
|
|
31
31
|
includeToolCalls?: boolean;
|
|
32
|
+
source?: 'stdout' | 'stderr';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PeekFlushOptions {
|
|
36
|
+
terminal?: boolean;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
interface ToolSummary {
|
|
@@ -44,7 +49,16 @@ interface ToolCallMemory {
|
|
|
44
49
|
summary_truncated?: boolean;
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
interface PendingForgeTool {
|
|
53
|
+
id: string;
|
|
54
|
+
tool: string;
|
|
55
|
+
summary: string;
|
|
56
|
+
summary_truncated?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
const PEEK_TOOL_SUMMARY_MAX_LENGTH = 200;
|
|
60
|
+
const FORGE_EXECUTE_PATTERN = /^● \[[^\]]+\] Execute \[([^\]]*)\]\s+(.+)$/;
|
|
61
|
+
const FORGE_FINISHED_PATTERN = /^● \[[^\]]+\] Finished(?:\s+\S+)?\s*$/;
|
|
48
62
|
|
|
49
63
|
function isGeminiAssistantMessageEvent(parsed: any): boolean {
|
|
50
64
|
return parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string';
|
|
@@ -343,13 +357,21 @@ export class PeekEventExtractor {
|
|
|
343
357
|
private pending = '';
|
|
344
358
|
private geminiAssistantBuffer = '';
|
|
345
359
|
private readonly includeToolCalls: boolean;
|
|
360
|
+
private readonly source: 'stdout' | 'stderr';
|
|
346
361
|
private readonly toolMemory = new Map<string, ToolCallMemory>();
|
|
362
|
+
private forgePendingTool: PendingForgeTool | null = null;
|
|
363
|
+
private forgeToolSequence = 0;
|
|
347
364
|
|
|
348
365
|
constructor(private readonly agent: PeekAgent, options: PeekEventExtractorOptions = {}) {
|
|
349
366
|
this.includeToolCalls = options.includeToolCalls === true;
|
|
367
|
+
this.source = options.source || 'stdout';
|
|
350
368
|
}
|
|
351
369
|
|
|
352
370
|
push(chunk: string, observedAt = new Date().toISOString()): PeekEvent[] {
|
|
371
|
+
if (this.agent === 'forge' && this.source === 'stderr') {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
|
|
353
375
|
if (!chunk) {
|
|
354
376
|
return [];
|
|
355
377
|
}
|
|
@@ -359,20 +381,32 @@ export class PeekEventExtractor {
|
|
|
359
381
|
return this.extractLines(lines, observedAt);
|
|
360
382
|
}
|
|
361
383
|
|
|
362
|
-
flush(observedAt = new Date().toISOString()): PeekEvent[] {
|
|
384
|
+
flush(observedAt = new Date().toISOString(), options: PeekFlushOptions = {}): PeekEvent[] {
|
|
385
|
+
if (this.agent === 'forge' && this.source === 'stderr') {
|
|
386
|
+
this.pending = '';
|
|
387
|
+
return [];
|
|
388
|
+
}
|
|
389
|
+
|
|
363
390
|
const events: PeekEvent[] = [];
|
|
364
391
|
|
|
365
392
|
if (this.pending) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
393
|
+
if (this.agent !== 'forge' || options.terminal === true) {
|
|
394
|
+
const line = this.pending;
|
|
395
|
+
this.pending = '';
|
|
396
|
+
events.push(...this.extractLines([line], observedAt));
|
|
397
|
+
}
|
|
369
398
|
}
|
|
370
399
|
|
|
371
400
|
events.push(...this.flushGeminiAssistantBuffer(observedAt));
|
|
401
|
+
events.push(...this.flushForgePendingTool(observedAt, options.terminal === true));
|
|
372
402
|
return events;
|
|
373
403
|
}
|
|
374
404
|
|
|
375
405
|
private extractLines(lines: string[], observedAt: string): PeekEvent[] {
|
|
406
|
+
if (this.agent === 'forge') {
|
|
407
|
+
return this.extractForgeLines(lines, observedAt);
|
|
408
|
+
}
|
|
409
|
+
|
|
376
410
|
const events: PeekEvent[] = [];
|
|
377
411
|
|
|
378
412
|
for (const line of lines) {
|
|
@@ -391,6 +425,67 @@ export class PeekEventExtractor {
|
|
|
391
425
|
return events;
|
|
392
426
|
}
|
|
393
427
|
|
|
428
|
+
private extractForgeLines(lines: string[], observedAt: string): PeekEvent[] {
|
|
429
|
+
const events: PeekEvent[] = [];
|
|
430
|
+
|
|
431
|
+
for (const line of lines) {
|
|
432
|
+
if (!line.trim()) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const summary = this.extractForgeMessage(line, 'Summary:');
|
|
437
|
+
if (summary !== null) {
|
|
438
|
+
events.push({ kind: 'message', ts: observedAt, text: summary });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const completed = this.extractForgeMessage(line, 'Completed successfully:');
|
|
443
|
+
if (completed !== null) {
|
|
444
|
+
events.push({ kind: 'message', ts: observedAt, text: completed });
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (this.includeToolCalls) {
|
|
449
|
+
const executeMatch = line.match(FORGE_EXECUTE_PATTERN);
|
|
450
|
+
if (executeMatch) {
|
|
451
|
+
events.push(...this.completeForgePendingTool(observedAt));
|
|
452
|
+
const [, rawTool, rawSummary] = executeMatch;
|
|
453
|
+
const tool = rawTool.trim() && !/\s/.test(rawTool.trim()) ? rawTool.trim() : 'shell';
|
|
454
|
+
const event = createToolCallEvent({
|
|
455
|
+
ts: observedAt,
|
|
456
|
+
phase: 'started',
|
|
457
|
+
id: `forge_${this.forgeToolSequence++}`,
|
|
458
|
+
tool,
|
|
459
|
+
command: rawSummary,
|
|
460
|
+
});
|
|
461
|
+
this.forgePendingTool = {
|
|
462
|
+
id: event.id!,
|
|
463
|
+
tool: event.tool,
|
|
464
|
+
summary: event.summary,
|
|
465
|
+
summary_truncated: event.summary_truncated,
|
|
466
|
+
};
|
|
467
|
+
events.push(event);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (FORGE_FINISHED_PATTERN.test(line)) {
|
|
472
|
+
events.push(...this.completeForgePendingTool(observedAt));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return events;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private extractForgeMessage(line: string, prefix: string): string | null {
|
|
481
|
+
if (!line.startsWith(prefix)) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const text = line.slice(prefix.length).trim();
|
|
486
|
+
return text || null;
|
|
487
|
+
}
|
|
488
|
+
|
|
394
489
|
private extractParsedEvent(parsed: any, observedAt: string): PeekEvent[] {
|
|
395
490
|
if (this.agent === 'gemini') {
|
|
396
491
|
const events = this.extractGeminiParsedEvent(parsed, observedAt);
|
|
@@ -446,6 +541,36 @@ export class PeekEventExtractor {
|
|
|
446
541
|
|
|
447
542
|
return [{ kind: 'message', ts: observedAt, text }];
|
|
448
543
|
}
|
|
544
|
+
|
|
545
|
+
private completeForgePendingTool(observedAt: string): PeekEvent[] {
|
|
546
|
+
if (!this.forgePendingTool) {
|
|
547
|
+
return [];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const pending = this.forgePendingTool;
|
|
551
|
+
this.forgePendingTool = null;
|
|
552
|
+
const event = createToolCallEvent({
|
|
553
|
+
ts: observedAt,
|
|
554
|
+
phase: 'completed',
|
|
555
|
+
id: pending.id,
|
|
556
|
+
tool: pending.tool,
|
|
557
|
+
status: 'unknown',
|
|
558
|
+
defaultStatus: 'unknown',
|
|
559
|
+
});
|
|
560
|
+
event.summary = pending.summary;
|
|
561
|
+
if (pending.summary_truncated) {
|
|
562
|
+
event.summary_truncated = true;
|
|
563
|
+
}
|
|
564
|
+
return [event];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private flushForgePendingTool(observedAt: string, terminal: boolean): PeekEvent[] {
|
|
568
|
+
if (this.agent !== 'forge' || !terminal) {
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return this.completeForgePendingTool(observedAt);
|
|
573
|
+
}
|
|
449
574
|
}
|
|
450
575
|
|
|
451
576
|
export class PeekMessageExtractor {
|
|
@@ -459,8 +584,8 @@ export class PeekMessageExtractor {
|
|
|
459
584
|
return this.toMessages(this.extractor.push(chunk, observedAt));
|
|
460
585
|
}
|
|
461
586
|
|
|
462
|
-
flush(observedAt = new Date().toISOString()): PeekMessage[] {
|
|
463
|
-
return this.toMessages(this.extractor.flush(observedAt));
|
|
587
|
+
flush(observedAt = new Date().toISOString(), options: PeekFlushOptions = {}): PeekMessage[] {
|
|
588
|
+
return this.toMessages(this.extractor.flush(observedAt, options));
|
|
464
589
|
}
|
|
465
590
|
|
|
466
591
|
private toMessages(events: PeekEvent[]): PeekMessage[] {
|
package/src/process-service.ts
CHANGED
|
@@ -256,8 +256,8 @@ export class ProcessService {
|
|
|
256
256
|
};
|
|
257
257
|
processes.push(result);
|
|
258
258
|
|
|
259
|
-
const stdoutExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls });
|
|
260
|
-
const stderrExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls });
|
|
259
|
+
const stdoutExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls, source: 'stdout' });
|
|
260
|
+
const stderrExtractor = new PeekEventExtractor(entry.toolType, { includeToolCalls, source: 'stderr' });
|
|
261
261
|
const onStdout = (data: Buffer | string) => {
|
|
262
262
|
appendPeekEvents(result, stdoutExtractor.push(data.toString(), new Date().toISOString()));
|
|
263
263
|
};
|
|
@@ -294,8 +294,9 @@ export class ProcessService {
|
|
|
294
294
|
for (const observer of observers) {
|
|
295
295
|
observer.entry.process.stdout?.off('data', observer.onStdout);
|
|
296
296
|
observer.entry.process.stderr?.off('data', observer.onStderr);
|
|
297
|
-
|
|
298
|
-
appendPeekEvents(observer.result, observer.
|
|
297
|
+
const terminal = observer.entry.status !== 'running';
|
|
298
|
+
appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs, { terminal }));
|
|
299
|
+
appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs, { terminal }));
|
|
299
300
|
observer.result.status = observer.entry.status;
|
|
300
301
|
}
|
|
301
302
|
}
|