ai-cli-mcp 2.17.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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from '../parsers.js';
2
+ import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekEventExtractor, PeekMessageExtractor } from '../parsers.js';
3
3
 
4
4
  describe('parseCodexOutput', () => {
5
5
  it('should parse basic Codex output with message and session_id', () => {
@@ -190,6 +190,326 @@ describe('PeekMessageExtractor', () => {
190
190
  });
191
191
  });
192
192
 
193
+ describe('PeekEventExtractor', () => {
194
+ const ts = '2026-04-12T02:10:00.000Z';
195
+
196
+ it('emits only message events when include_tool_calls is false', () => {
197
+ const extractor = new PeekEventExtractor('codex', { includeToolCalls: false });
198
+ const output = [
199
+ '{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"echo secret","status":"in_progress"}}',
200
+ '{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"echo secret","aggregated_output":"secret output\\n","exit_code":0,"status":"completed"}}',
201
+ '{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Visible Codex message"}}',
202
+ ].join('\n') + '\n';
203
+
204
+ expect(extractor.push(output, ts)).toEqual([
205
+ { kind: 'message', ts, text: 'Visible Codex message' },
206
+ ]);
207
+ });
208
+
209
+ it('emits Codex command and MCP tool_call events without raw output when include_tool_calls is true', () => {
210
+ const extractor = new PeekEventExtractor('codex', { includeToolCalls: true });
211
+ const output = [
212
+ '{"type":"item.started","item":{"id":"cmd_0","type":"command_execution","command":"/bin/sh -c \\"echo secret\\"","status":"in_progress"}}',
213
+ '{"type":"item.completed","item":{"id":"cmd_0","type":"command_execution","command":"/bin/sh -c \\"echo secret\\"","aggregated_output":"secret output\\n","exit_code":0,"status":"completed"}}',
214
+ '{"type":"item.started","item":{"id":"mcp_0","type":"mcp_tool_call","server":"acm","tool":"list_processes","arguments":{},"status":"in_progress"}}',
215
+ '{"type":"item.completed","item":{"id":"mcp_0","type":"mcp_tool_call","server":"acm","tool":"list_processes","arguments":{},"result":{"content":[{"type":"text","text":"secret result"}]},"status":"completed"}}',
216
+ ].join('\n') + '\n';
217
+
218
+ expect(extractor.push(output, ts)).toEqual([
219
+ {
220
+ kind: 'tool_call',
221
+ ts,
222
+ phase: 'started',
223
+ id: 'cmd_0',
224
+ tool: 'command_execution',
225
+ summary: '/bin/sh -c "echo secret"',
226
+ },
227
+ {
228
+ kind: 'tool_call',
229
+ ts,
230
+ phase: 'completed',
231
+ id: 'cmd_0',
232
+ tool: 'command_execution',
233
+ summary: '/bin/sh -c "echo secret"',
234
+ status: 'success',
235
+ exit_code: 0,
236
+ },
237
+ {
238
+ kind: 'tool_call',
239
+ ts,
240
+ phase: 'started',
241
+ id: 'mcp_0',
242
+ tool: 'list_processes',
243
+ server: 'acm',
244
+ summary: 'acm.list_processes',
245
+ },
246
+ {
247
+ kind: 'tool_call',
248
+ ts,
249
+ phase: 'completed',
250
+ id: 'mcp_0',
251
+ tool: 'list_processes',
252
+ server: 'acm',
253
+ summary: 'acm.list_processes',
254
+ status: 'success',
255
+ },
256
+ ]);
257
+ });
258
+
259
+ it('emits Claude MCP tool_call events paired by id', () => {
260
+ const extractor = new PeekEventExtractor('claude', { includeToolCalls: true });
261
+ const output = [
262
+ '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"mcp__acm__list_processes","input":{}}]}}',
263
+ '{"type":"user","message":{"content":[{"tool_use_id":"toolu_1","type":"tool_result","content":[{"type":"text","text":"secret result"}]}]}}',
264
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}}',
265
+ ].join('\n') + '\n';
266
+
267
+ expect(extractor.push(output, ts)).toEqual([
268
+ {
269
+ kind: 'tool_call',
270
+ ts,
271
+ phase: 'started',
272
+ id: 'toolu_1',
273
+ tool: 'mcp__acm__list_processes',
274
+ server: 'acm',
275
+ summary: 'acm.list_processes',
276
+ },
277
+ {
278
+ kind: 'tool_call',
279
+ ts,
280
+ phase: 'completed',
281
+ id: 'toolu_1',
282
+ tool: 'mcp__acm__list_processes',
283
+ server: 'acm',
284
+ summary: 'acm.list_processes',
285
+ status: 'success',
286
+ },
287
+ { kind: 'message', ts, text: 'Done.' },
288
+ ]);
289
+ });
290
+
291
+ it('emits Gemini MCP tool_call events and joined assistant message events', () => {
292
+ const extractor = new PeekEventExtractor('gemini', { includeToolCalls: true });
293
+ const output = [
294
+ '{"type":"tool_use","timestamp":"2026-04-12T02:56:29.992Z","tool_name":"mcp_acm_list_processes","tool_id":"mcp_1","parameters":{}}',
295
+ '{"type":"tool_result","timestamp":"2026-04-12T02:56:30.059Z","tool_id":"mcp_1","status":"success","output":"secret result"}',
296
+ '{"type":"message","timestamp":"2026-04-12T02:56:32.855Z","role":"assistant","content":"The tool ","delta":true}',
297
+ '{"type":"message","timestamp":"2026-04-12T02:56:32.902Z","role":"assistant","content":"succeeded.","delta":true}',
298
+ '{"type":"result","timestamp":"2026-04-12T02:56:32.954Z","status":"success","stats":{"tool_calls":1}}',
299
+ ].join('\n') + '\n';
300
+
301
+ expect(extractor.push(output, ts)).toEqual([
302
+ {
303
+ kind: 'tool_call',
304
+ ts,
305
+ phase: 'started',
306
+ id: 'mcp_1',
307
+ tool: 'mcp_acm_list_processes',
308
+ server: 'acm',
309
+ summary: 'acm.list_processes',
310
+ },
311
+ {
312
+ kind: 'tool_call',
313
+ ts,
314
+ phase: 'completed',
315
+ id: 'mcp_1',
316
+ tool: 'mcp_acm_list_processes',
317
+ server: 'acm',
318
+ summary: 'acm.list_processes',
319
+ status: 'success',
320
+ },
321
+ { kind: 'message', ts, text: 'The tool succeeded.' },
322
+ ]);
323
+ });
324
+
325
+ it('emits OpenCode completed MCP tool_call events from tool_use state', () => {
326
+ const extractor = new PeekEventExtractor('opencode', { includeToolCalls: true });
327
+ const output = [
328
+ '{"type":"tool_use","timestamp":1775962663837,"sessionID":"ses-1","part":{"id":"part-1","type":"tool","tool":"acm_list_processes","callID":"call_1","state":{"status":"completed","input":{},"output":"secret result","metadata":{"truncated":false},"time":{"start":1775962663834,"end":1775962663837}}}}',
329
+ ].join('\n') + '\n';
330
+
331
+ expect(extractor.push(output, ts)).toEqual([
332
+ {
333
+ kind: 'tool_call',
334
+ ts,
335
+ phase: 'completed',
336
+ id: 'call_1',
337
+ tool: 'acm_list_processes',
338
+ server: 'acm',
339
+ summary: 'acm.list_processes',
340
+ status: 'success',
341
+ duration_ms: 3,
342
+ },
343
+ ]);
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
+ });
511
+ });
512
+
193
513
  describe('parseGeminiOutput', () => {
194
514
  it('should parse legacy final JSON output', () => {
195
515
  const output = JSON.stringify({
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { appendPeekMessages, validatePeekPids, validatePeekTimeSec, type PeekProcessResult } from '../peek.js';
2
+ import { appendPeekEvents, validatePeekPids, validatePeekTimeSec, type PeekProcessResult } from '../peek.js';
3
3
 
4
4
  describe('peek helpers', () => {
5
5
  it('dedupes pids while preserving first occurrence order', () => {
@@ -17,27 +17,28 @@ describe('peek helpers', () => {
17
17
  expect(() => validatePeekTimeSec(61)).toThrow(/positive integer/);
18
18
  });
19
19
 
20
- it('keeps the first 50 messages and marks truncation when later messages are dropped', () => {
20
+ it('keeps the first 50 events and marks truncation when later events are dropped', () => {
21
21
  const process: PeekProcessResult = {
22
22
  pid: 123,
23
23
  agent: 'codex',
24
24
  status: 'running',
25
- messages: [],
25
+ events: [],
26
26
  truncated: false,
27
27
  error: null,
28
28
  };
29
29
 
30
- appendPeekMessages(
30
+ appendPeekEvents(
31
31
  process,
32
32
  Array.from({ length: 55 }, (_, index) => ({
33
+ kind: 'message' as const,
33
34
  ts: '2026-04-11T12:34:56.789Z',
34
35
  text: `message ${index}`,
35
36
  })),
36
37
  );
37
38
 
38
- expect(process.messages).toHaveLength(50);
39
- expect(process.messages[0].text).toBe('message 0');
40
- expect(process.messages[49].text).toBe('message 49');
39
+ expect(process.events).toHaveLength(50);
40
+ expect(process.events[0]).toMatchObject({ kind: 'message', text: 'message 0' });
41
+ expect(process.events[49]).toMatchObject({ kind: 'message', text: 'message 49' });
41
42
  expect(process.truncated).toBe(true);
42
43
  });
43
44
  });
@@ -166,8 +166,9 @@ describe('Process Management Tests', () => {
166
166
  pid: 12345,
167
167
  agent: 'claude',
168
168
  status: 'completed',
169
- messages: [
169
+ events: [
170
170
  {
171
+ kind: 'message',
171
172
  ts: expect.any(String),
172
173
  text: 'new message',
173
174
  },
@@ -179,7 +180,7 @@ describe('Process Management Tests', () => {
179
180
  pid: 99999,
180
181
  agent: null,
181
182
  status: 'not_found',
182
- messages: [],
183
+ events: [],
183
184
  truncated: false,
184
185
  error: 'process not found',
185
186
  });
@@ -232,8 +233,9 @@ describe('Process Management Tests', () => {
232
233
  pid: 12346,
233
234
  agent: 'opencode',
234
235
  status: 'completed',
235
- messages: [
236
+ events: [
236
237
  {
238
+ kind: 'message',
237
239
  ts: expect.any(String),
238
240
  text: 'OpenCode visible text',
239
241
  },
@@ -291,8 +293,9 @@ describe('Process Management Tests', () => {
291
293
  pid: 12347,
292
294
  agent: 'gemini',
293
295
  status: 'completed',
294
- messages: [
296
+ events: [
295
297
  {
298
+ kind: 'message',
296
299
  ts: expect.any(String),
297
300
  text: 'Visible Gemini text',
298
301
  },
@@ -302,6 +305,171 @@ describe('Process Management Tests', () => {
302
305
  });
303
306
  });
304
307
 
308
+ it('should include normalized tool_call events when requested', async () => {
309
+ const { handlers } = await setupServer();
310
+
311
+ const mockProcess = new EventEmitter() as any;
312
+ mockProcess.pid = 12348;
313
+ mockProcess.stdout = new EventEmitter();
314
+ mockProcess.stderr = new EventEmitter();
315
+ mockProcess.kill = vi.fn();
316
+
317
+ mockSpawn.mockReturnValue(mockProcess);
318
+
319
+ const callToolHandler = handlers.get('callTool')!;
320
+ await callToolHandler!({
321
+ params: {
322
+ name: 'run',
323
+ arguments: {
324
+ prompt: 'claude mcp peek prompt',
325
+ workFolder: '/tmp',
326
+ model: 'haiku',
327
+ }
328
+ }
329
+ });
330
+
331
+ const peekPromise = callToolHandler!({
332
+ params: {
333
+ name: 'peek',
334
+ arguments: {
335
+ pids: [12348],
336
+ peek_time_sec: 1,
337
+ include_tool_calls: true,
338
+ }
339
+ }
340
+ });
341
+
342
+ setTimeout(() => {
343
+ mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"mcp__acm__list_processes","input":{}}]}}\n');
344
+ mockProcess.stdout.emit('data', '{"type":"user","message":{"content":[{"tool_use_id":"toolu_1","type":"tool_result","content":[{"type":"text","text":"secret result"}]}]}}\n');
345
+ mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"MCP succeeded."}]}}\n');
346
+ mockProcess.emit('close', 0);
347
+ }, 10);
348
+
349
+ const result = await peekPromise;
350
+ const response = JSON.parse(result.content[0].text);
351
+
352
+ expect(response.processes).toHaveLength(1);
353
+ expect(response.processes[0]).toMatchObject({
354
+ pid: 12348,
355
+ agent: 'claude',
356
+ status: 'completed',
357
+ events: [
358
+ {
359
+ kind: 'tool_call',
360
+ phase: 'started',
361
+ id: 'toolu_1',
362
+ tool: 'mcp__acm__list_processes',
363
+ server: 'acm',
364
+ summary: 'acm.list_processes',
365
+ },
366
+ {
367
+ kind: 'tool_call',
368
+ phase: 'completed',
369
+ id: 'toolu_1',
370
+ tool: 'mcp__acm__list_processes',
371
+ server: 'acm',
372
+ summary: 'acm.list_processes',
373
+ status: 'success',
374
+ },
375
+ {
376
+ kind: 'message',
377
+ ts: expect.any(String),
378
+ text: 'MCP succeeded.',
379
+ },
380
+ ],
381
+ truncated: false,
382
+ error: null,
383
+ });
384
+ expect(JSON.stringify(response)).not.toContain('secret result');
385
+ });
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
+
305
473
  it('should handle process with model parameter', async () => {
306
474
  const { handlers } = await setupServer();
307
475
 
package/src/app/cli.ts CHANGED
@@ -9,7 +9,7 @@ export const CLI_HELP_TEXT = `Usage: ai-cli <command> [options]
9
9
  Commands:
10
10
  run Start an AI CLI process in the background
11
11
  wait Wait for one or more pids
12
- peek Observe new natural-language agent messages for a short window
12
+ peek Observe new agent events for a short window
13
13
  ps List tracked processes
14
14
  result Get the current result for a pid
15
15
  kill Terminate a tracked pid
@@ -62,12 +62,13 @@ Options:
62
62
 
63
63
  export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
64
64
 
65
- Observe new natural-language agent messages for a short one-shot window.
66
- In v1, message extraction is supported for Codex, Claude, OpenCode, and Gemini; Forge returns status with messages: [].
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, 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:
70
70
  --time <seconds> Observation window in seconds. Defaults to 10, maximum 60
71
+ --include-tool-calls Include normalized tool_call events without raw tool output
71
72
  --help, -h Show this help message
72
73
  `;
73
74
 
@@ -131,7 +132,7 @@ interface CliDeps {
131
132
  listProcesses: () => Promise<any>;
132
133
  getProcessResult: (pid: number, verbose: boolean) => Promise<any>;
133
134
  waitForProcesses: (pids: number[], timeoutSeconds?: number, verbose?: boolean) => Promise<any>;
134
- peekProcesses: (pids: number[], peekTimeSec?: number) => Promise<any>;
135
+ peekProcesses: (pids: number[], peekTimeSec?: number, includeToolCalls?: boolean) => Promise<any>;
135
136
  killProcess: (pid: number) => Promise<any>;
136
137
  cleanupProcesses: () => Promise<any>;
137
138
  getDoctorStatus: () => any;
@@ -154,7 +155,7 @@ const defaultDeps: CliDeps = {
154
155
  listProcesses: () => getCliProcessService().listProcesses(),
155
156
  getProcessResult: (pid, verbose) => getCliProcessService().getProcessResult(pid, verbose),
156
157
  waitForProcesses: (pids, timeoutSeconds, verbose) => getCliProcessService().waitForProcesses(pids, timeoutSeconds, verbose),
157
- peekProcesses: (pids, peekTimeSec) => getCliProcessService().peekProcesses(pids, peekTimeSec),
158
+ peekProcesses: (pids, peekTimeSec, includeToolCalls) => getCliProcessService().peekProcesses(pids, peekTimeSec, includeToolCalls),
158
159
  killProcess: (pid) => getCliProcessService().killProcess(pid),
159
160
  cleanupProcesses: () => getCliProcessService().cleanupProcesses(),
160
161
  getDoctorStatus: () => getCliDoctorStatus(),
@@ -367,7 +368,7 @@ export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promi
367
368
  return 1;
368
369
  }
369
370
 
370
- writeJson(stdout, await peekProcesses(pids, peekTimeSec));
371
+ writeJson(stdout, await peekProcesses(pids, peekTimeSec, 'include-tool-calls' in flags || 'include_tool_calls' in flags));
371
372
  return 0;
372
373
  }
373
374
 
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 agent messages 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 messages: [].',
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: {
@@ -246,6 +246,10 @@ ${getSupportedModelsDescription()}
246
246
  type: 'number',
247
247
  description: 'Optional positive integer observation window in seconds. Defaults to 10; maximum is 60.',
248
248
  },
249
+ include_tool_calls: {
250
+ type: 'boolean',
251
+ description: 'Optional: include normalized tool_call events without raw tool output. Defaults to false.',
252
+ },
249
253
  },
250
254
  required: ['pids'],
251
255
  },
@@ -384,16 +388,21 @@ ${getSupportedModelsDescription()}
384
388
  private async handlePeek(toolArguments: any): Promise<ServerResult> {
385
389
  let pids: number[];
386
390
  let peekTimeSec: number;
391
+ let includeToolCalls: boolean;
387
392
 
388
393
  try {
389
394
  pids = validatePeekPids(toolArguments.pids);
390
395
  peekTimeSec = validatePeekTimeSec(toolArguments.peek_time_sec);
396
+ if (toolArguments.include_tool_calls !== undefined && typeof toolArguments.include_tool_calls !== 'boolean') {
397
+ throw new Error('include_tool_calls must be a boolean when provided');
398
+ }
399
+ includeToolCalls = toolArguments.include_tool_calls === true;
391
400
  } catch (error: any) {
392
401
  throw new McpError(ErrorCode.InvalidParams, error.message);
393
402
  }
394
403
 
395
404
  try {
396
- const response = await this.processService.peekProcesses(pids, peekTimeSec);
405
+ const response = await this.processService.peekProcesses(pids, peekTimeSec, includeToolCalls);
397
406
  return {
398
407
  content: [{
399
408
  type: 'text',