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 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
- - 未知のイベント形状はデフォルトで拒否します。Forge など、まだ明示対応されていない管理対象エージェントは、実際のプロセス状態を返しつつ、`events: []`、`truncated: false`、`error: null` にします。
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"`, and Gemini stream-json `message` events where `role` is `"assistant"`.
298
- - When tool calls are included, `tool_call` events are normalized for Codex command/MCP calls, Claude tool use/results, Gemini tool use/results, and OpenCode completed tool use events. Tool summaries are bounded one-line strings derived from tool names and input metadata only. 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, such as Forge until explicitly supported, return their real process status with `events: []`, `truncated: false`, and `error: null`.
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 Gemini; Forge returns status with events: [].
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 Gemini; Forge returns status with events: []. Tool calls exclude raw tool output.',
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
- appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs));
243
- appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs));
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
- const line = this.pending;
273
- this.pending = '';
274
- events.push(...this.extractLines([line], observedAt));
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
@@ -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
- appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs));
214
- appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs));
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.18.0",
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 Gemini; Forge returns status with events: [].
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 Gemini; Forge returns status with events: []. Tool calls exclude raw tool output.',
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
- appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs));
339
- appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs));
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
- const line = this.pending;
367
- this.pending = '';
368
- events.push(...this.extractLines([line], observedAt));
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[] {
@@ -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
- appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs));
298
- appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs));
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
  }