ai-cli-mcp 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -101,6 +101,165 @@ describe('Process Management Tests', () => {
101
101
  expect(response.status).toBe('started');
102
102
  expect(response.message).toBe('claude process started successfully');
103
103
  });
104
+ it('should peek only natural-language messages observed after registration', async () => {
105
+ const { handlers } = await setupServer();
106
+ const mockProcess = new EventEmitter();
107
+ mockProcess.pid = 12345;
108
+ mockProcess.stdout = new EventEmitter();
109
+ mockProcess.stderr = new EventEmitter();
110
+ mockProcess.kill = vi.fn();
111
+ mockSpawn.mockReturnValue(mockProcess);
112
+ const callToolHandler = handlers.get('callTool');
113
+ await callToolHandler({
114
+ params: {
115
+ name: 'run',
116
+ arguments: {
117
+ prompt: 'test prompt',
118
+ workFolder: '/tmp'
119
+ }
120
+ }
121
+ });
122
+ mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"old message"}]}}\n');
123
+ const peekPromise = callToolHandler({
124
+ params: {
125
+ name: 'peek',
126
+ arguments: {
127
+ pids: [12345, 12345, 99999],
128
+ peek_time_sec: 1,
129
+ }
130
+ }
131
+ });
132
+ setTimeout(() => {
133
+ mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"new message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}\n');
134
+ mockProcess.stdout.emit('data', '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}\n');
135
+ mockProcess.emit('close', 0);
136
+ }, 10);
137
+ const result = await peekPromise;
138
+ const response = JSON.parse(result.content[0].text);
139
+ expect(response.processes).toHaveLength(2);
140
+ expect(response.processes[0]).toMatchObject({
141
+ pid: 12345,
142
+ agent: 'claude',
143
+ status: 'completed',
144
+ messages: [
145
+ {
146
+ ts: expect.any(String),
147
+ text: 'new message',
148
+ },
149
+ ],
150
+ truncated: false,
151
+ error: null,
152
+ });
153
+ expect(response.processes[1]).toEqual({
154
+ pid: 99999,
155
+ agent: null,
156
+ status: 'not_found',
157
+ messages: [],
158
+ truncated: false,
159
+ error: 'process not found',
160
+ });
161
+ });
162
+ it('should peek OpenCode text events and exclude OpenCode tool output', async () => {
163
+ const { handlers } = await setupServer();
164
+ const mockProcess = new EventEmitter();
165
+ mockProcess.pid = 12346;
166
+ mockProcess.stdout = new EventEmitter();
167
+ mockProcess.stderr = new EventEmitter();
168
+ mockProcess.kill = vi.fn();
169
+ mockSpawn.mockReturnValue(mockProcess);
170
+ const callToolHandler = handlers.get('callTool');
171
+ await callToolHandler({
172
+ params: {
173
+ name: 'run',
174
+ arguments: {
175
+ prompt: 'opencode peek prompt',
176
+ workFolder: '/tmp',
177
+ model: 'opencode',
178
+ }
179
+ }
180
+ });
181
+ const peekPromise = callToolHandler({
182
+ params: {
183
+ name: 'peek',
184
+ arguments: {
185
+ pids: [12346],
186
+ peek_time_sec: 1,
187
+ }
188
+ }
189
+ });
190
+ setTimeout(() => {
191
+ mockProcess.stdout.emit('data', '{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}\n');
192
+ mockProcess.stdout.emit('data', '{"type":"tool_use","timestamp":1775918783606,"sessionID":"ses-1","part":{"type":"tool","state":{"output":"secret command output"},"metadata":{"output":"secret metadata output"}}}\n');
193
+ mockProcess.emit('close', 0);
194
+ }, 10);
195
+ const result = await peekPromise;
196
+ const response = JSON.parse(result.content[0].text);
197
+ expect(response.processes).toHaveLength(1);
198
+ expect(response.processes[0]).toMatchObject({
199
+ pid: 12346,
200
+ agent: 'opencode',
201
+ status: 'completed',
202
+ messages: [
203
+ {
204
+ ts: expect.any(String),
205
+ text: 'OpenCode visible text',
206
+ },
207
+ ],
208
+ truncated: false,
209
+ error: null,
210
+ });
211
+ });
212
+ it('should peek Gemini assistant message events and exclude tool output', async () => {
213
+ const { handlers } = await setupServer();
214
+ const mockProcess = new EventEmitter();
215
+ mockProcess.pid = 12347;
216
+ mockProcess.stdout = new EventEmitter();
217
+ mockProcess.stderr = new EventEmitter();
218
+ mockProcess.kill = vi.fn();
219
+ mockSpawn.mockReturnValue(mockProcess);
220
+ const callToolHandler = handlers.get('callTool');
221
+ await callToolHandler({
222
+ params: {
223
+ name: 'run',
224
+ arguments: {
225
+ prompt: 'gemini peek prompt',
226
+ workFolder: '/tmp',
227
+ model: 'gemini-2.5-pro',
228
+ }
229
+ }
230
+ });
231
+ const peekPromise = callToolHandler({
232
+ params: {
233
+ name: 'peek',
234
+ arguments: {
235
+ pids: [12347],
236
+ peek_time_sec: 1,
237
+ }
238
+ }
239
+ });
240
+ setTimeout(() => {
241
+ mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}\n');
242
+ mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}\n');
243
+ mockProcess.stdout.emit('data', '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}\n');
244
+ mockProcess.emit('close', 0);
245
+ }, 10);
246
+ const result = await peekPromise;
247
+ const response = JSON.parse(result.content[0].text);
248
+ expect(response.processes).toHaveLength(1);
249
+ expect(response.processes[0]).toMatchObject({
250
+ pid: 12347,
251
+ agent: 'gemini',
252
+ status: 'completed',
253
+ messages: [
254
+ {
255
+ ts: expect.any(String),
256
+ text: 'Visible Gemini text',
257
+ },
258
+ ],
259
+ truncated: false,
260
+ error: null,
261
+ });
262
+ });
104
263
  it('should handle process with model parameter', async () => {
105
264
  const { handlers } = await setupServer();
106
265
  const mockProcess = new EventEmitter();
@@ -407,14 +407,15 @@ describe('ClaudeCodeServer Unit Tests', () => {
407
407
  // Test the handler
408
408
  const handler = listToolsCall[1];
409
409
  const result = await handler();
410
- expect(result.tools).toHaveLength(6);
410
+ expect(result.tools).toHaveLength(7);
411
411
  expect(result.tools[0].name).toBe('run');
412
412
  expect(result.tools[0].description).toContain('AI Agent Runner');
413
413
  expect(result.tools[1].name).toBe('list_processes');
414
414
  expect(result.tools[2].name).toBe('get_result');
415
415
  expect(result.tools[3].name).toBe('wait');
416
- expect(result.tools[4].name).toBe('kill_process');
417
- expect(result.tools[5].name).toBe('cleanup_processes');
416
+ expect(result.tools[4].name).toBe('peek');
417
+ expect(result.tools[5].name).toBe('kill_process');
418
+ expect(result.tools[6].name).toBe('cleanup_processes');
418
419
  });
419
420
  it('should handle CallToolRequest', async () => {
420
421
  mockHomedir.mockReturnValue('/home/user');
package/dist/app/cli.js CHANGED
@@ -2,11 +2,13 @@ import { runMcpServer } from './mcp.js';
2
2
  import { CliProcessService } from '../cli-process-service.js';
3
3
  import { getCliDoctorStatus } from '../cli-utils.js';
4
4
  import { getModelsPayload } from '../model-catalog.js';
5
+ import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
5
6
  export const CLI_HELP_TEXT = `Usage: ai-cli <command> [options]
6
7
 
7
8
  Commands:
8
9
  run Start an AI CLI process in the background
9
10
  wait Wait for one or more pids
11
+ peek Observe new natural-language agent messages for a short window
10
12
  ps List tracked processes
11
13
  result Get the current result for a pid
12
14
  kill Terminate a tracked pid
@@ -53,6 +55,16 @@ Options:
53
55
  --verbose Return full metadata and detailed parsed output
54
56
  --help, -h Show this help message
55
57
  `;
58
+ export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
59
+
60
+ Observe new natural-language agent messages for a short one-shot window.
61
+ In v1, message extraction is supported for Codex, Claude, OpenCode, and Gemini; Forge returns status with messages: [].
62
+ This is not a history API, gapless streaming, or stdout/stderr tailing. No --follow mode is available in v1.
63
+
64
+ Options:
65
+ --time <seconds> Observation window in seconds. Defaults to 10, maximum 60
66
+ --help, -h Show this help message
67
+ `;
56
68
  export const KILL_HELP_TEXT = `Usage: ai-cli kill <pid>
57
69
 
58
70
  Terminate a tracked process.
@@ -107,6 +119,7 @@ const defaultDeps = {
107
119
  listProcesses: () => getCliProcessService().listProcesses(),
108
120
  getProcessResult: (pid, verbose) => getCliProcessService().getProcessResult(pid, verbose),
109
121
  waitForProcesses: (pids, timeoutSeconds, verbose) => getCliProcessService().waitForProcesses(pids, timeoutSeconds, verbose),
122
+ peekProcesses: (pids, peekTimeSec) => getCliProcessService().peekProcesses(pids, peekTimeSec),
110
123
  killProcess: (pid) => getCliProcessService().killProcess(pid),
111
124
  cleanupProcesses: () => getCliProcessService().cleanupProcesses(),
112
125
  getDoctorStatus: () => getCliDoctorStatus(),
@@ -161,8 +174,11 @@ function writeJson(stdout, value) {
161
174
  function hasHelpFlag(flags) {
162
175
  return 'help' in flags || 'h' in flags;
163
176
  }
177
+ function parsePeekCliPids(values) {
178
+ return validatePeekPids(values.map((value) => Number(value)));
179
+ }
164
180
  export async function runCli(argv, deps = {}) {
165
- const { stdout, stderr, startMcpServer, runProcess, listProcesses, getProcessResult, waitForProcesses, killProcess, cleanupProcesses, getDoctorStatus, } = { ...defaultDeps, ...deps };
181
+ const { stdout, stderr, startMcpServer, runProcess, listProcesses, getProcessResult, waitForProcesses, peekProcesses, killProcess, cleanupProcesses, getDoctorStatus, } = { ...defaultDeps, ...deps };
166
182
  const [command] = argv;
167
183
  if (!command || command === 'help' || command === '--help' || command === '-h') {
168
184
  stdout(CLI_HELP_TEXT);
@@ -258,6 +274,32 @@ export async function runCli(argv, deps = {}) {
258
274
  writeJson(stdout, await waitForProcesses(pids, timeout, 'verbose' in flags));
259
275
  return 0;
260
276
  }
277
+ if (command === 'peek') {
278
+ const { positionals, flags } = parseArgs(argv.slice(1));
279
+ if (hasHelpFlag(flags)) {
280
+ stdout(PEEK_HELP_TEXT);
281
+ return 0;
282
+ }
283
+ if ('follow' in flags) {
284
+ stderr('peek does not support --follow in v1\n');
285
+ stdout(CLI_HELP_TEXT);
286
+ return 1;
287
+ }
288
+ let pids;
289
+ let peekTimeSec;
290
+ try {
291
+ pids = parsePeekCliPids(positionals);
292
+ const timeRaw = getFirstFlag(flags, ['time']);
293
+ peekTimeSec = validatePeekTimeSec(timeRaw === undefined ? undefined : Number(timeRaw));
294
+ }
295
+ catch (error) {
296
+ stderr(`${error.message}\n`);
297
+ stdout(CLI_HELP_TEXT);
298
+ return 1;
299
+ }
300
+ writeJson(stdout, await peekProcesses(pids, peekTimeSec));
301
+ return 0;
302
+ }
261
303
  if (command === 'kill') {
262
304
  const { positionals, flags } = parseArgs(argv.slice(1));
263
305
  if (hasHelpFlag(flags)) {
package/dist/app/mcp.js CHANGED
@@ -4,6 +4,7 @@ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } f
4
4
  import { spawn } from 'node:child_process';
5
5
  import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from '../cli-utils.js';
6
6
  import { getModelParameterDescription, getSupportedModelsDescription } from '../model-catalog.js';
7
+ import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
7
8
  import { ProcessService } from '../process-service.js';
8
9
  // Server version - update this when releasing new versions
9
10
  const SERVER_VERSION = "2.2.0";
@@ -208,6 +209,25 @@ ${getSupportedModelsDescription()}
208
209
  required: ['pids'],
209
210
  },
210
211
  },
212
+ {
213
+ name: 'peek',
214
+ 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: [].',
215
+ inputSchema: {
216
+ type: 'object',
217
+ properties: {
218
+ pids: {
219
+ type: 'array',
220
+ items: { type: 'number' },
221
+ description: 'Process IDs returned by run. Duplicates are deduplicated server-side, preserving first occurrence order. Unknown PIDs are returned per process as not_found.',
222
+ },
223
+ peek_time_sec: {
224
+ type: 'number',
225
+ description: 'Optional positive integer observation window in seconds. Defaults to 10; maximum is 60.',
226
+ },
227
+ },
228
+ required: ['pids'],
229
+ },
230
+ },
211
231
  {
212
232
  name: 'kill_process',
213
233
  description: 'Terminate a running AI agent process by PID.',
@@ -245,6 +265,8 @@ ${getSupportedModelsDescription()}
245
265
  return this.handleGetResult(toolArguments);
246
266
  case 'wait':
247
267
  return this.handleWait(toolArguments);
268
+ case 'peek':
269
+ return this.handlePeek(toolArguments);
248
270
  case 'kill_process':
249
271
  return this.handleKillProcess(toolArguments);
250
272
  case 'cleanup_processes':
@@ -326,6 +348,29 @@ ${getSupportedModelsDescription()}
326
348
  throw new McpError(code, error.message);
327
349
  }
328
350
  }
351
+ async handlePeek(toolArguments) {
352
+ let pids;
353
+ let peekTimeSec;
354
+ try {
355
+ pids = validatePeekPids(toolArguments.pids);
356
+ peekTimeSec = validatePeekTimeSec(toolArguments.peek_time_sec);
357
+ }
358
+ catch (error) {
359
+ throw new McpError(ErrorCode.InvalidParams, error.message);
360
+ }
361
+ try {
362
+ const response = await this.processService.peekProcesses(pids, peekTimeSec);
363
+ return {
364
+ content: [{
365
+ type: 'text',
366
+ text: JSON.stringify(response, null, 2)
367
+ }]
368
+ };
369
+ }
370
+ catch (error) {
371
+ throw new McpError(ErrorCode.InternalError, `Failed to peek processes: ${error.message}`);
372
+ }
373
+ }
329
374
  async handleKillProcess(toolArguments) {
330
375
  if (!toolArguments.pid || typeof toolArguments.pid !== 'number') {
331
376
  throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pid');
@@ -159,7 +159,7 @@ export function buildCliCommand(options) {
159
159
  }
160
160
  else if (agent === 'gemini') {
161
161
  cliPath = options.cliPaths.gemini;
162
- args = ['-y', '--output-format', 'json'];
162
+ args = ['-y', '--output-format', 'stream-json'];
163
163
  if (options.session_id && typeof options.session_id === 'string') {
164
164
  args.push('-r', options.session_id);
165
165
  }
@@ -1,11 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
3
3
  import { join, basename, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { buildCliCommand } from './cli-builder.js';
6
6
  import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
7
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
7
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
8
8
  import { buildProcessResult } from './process-result.js';
9
+ import { appendPeekMessages, buildNotFoundPeekProcess, observedDurationSec, validatePeekPids, validatePeekTimeSec, } from './peek.js';
9
10
  function resolveDefaultStateDir() {
10
11
  return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
11
12
  }
@@ -174,6 +175,79 @@ export class CliProcessService {
174
175
  await new Promise((resolve) => setTimeout(resolve, 50));
175
176
  }
176
177
  }
178
+ async peekProcesses(pids, peekTimeSec = 10) {
179
+ const targetPids = validatePeekPids(pids);
180
+ const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
181
+ const processes = [];
182
+ const observers = [];
183
+ for (const pid of targetPids) {
184
+ let process;
185
+ try {
186
+ process = this.refreshStatus(this.readProcess(pid));
187
+ }
188
+ catch {
189
+ processes.push(buildNotFoundPeekProcess(pid));
190
+ continue;
191
+ }
192
+ const result = {
193
+ pid,
194
+ agent: process.toolType,
195
+ status: process.status,
196
+ messages: [],
197
+ truncated: false,
198
+ error: null,
199
+ };
200
+ processes.push(result);
201
+ observers.push({
202
+ process,
203
+ result,
204
+ stdoutExtractor: new PeekMessageExtractor(process.toolType),
205
+ stderrExtractor: new PeekMessageExtractor(process.toolType),
206
+ stdoutOffset: this.fileSizeSafe(process.stdoutPath),
207
+ stderrOffset: this.fileSizeSafe(process.stderrPath),
208
+ });
209
+ }
210
+ const startedAt = new Date();
211
+ const startedAtMs = Date.now();
212
+ const deadlineMs = startedAtMs + targetPeekTimeSec * 1000;
213
+ while (Date.now() <= deadlineMs) {
214
+ const observedAt = new Date().toISOString();
215
+ let allTerminal = true;
216
+ for (const observer of observers) {
217
+ const stdoutRead = this.readTextFromOffset(observer.process.stdoutPath, observer.stdoutOffset);
218
+ observer.stdoutOffset = stdoutRead.offset;
219
+ appendPeekMessages(observer.result, observer.stdoutExtractor.push(stdoutRead.text, observedAt));
220
+ const stderrRead = this.readTextFromOffset(observer.process.stderrPath, observer.stderrOffset);
221
+ observer.stderrOffset = stderrRead.offset;
222
+ appendPeekMessages(observer.result, observer.stderrExtractor.push(stderrRead.text, observedAt));
223
+ observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
224
+ observer.result.status = observer.process.status;
225
+ if (observer.process.status === 'running') {
226
+ allTerminal = false;
227
+ }
228
+ }
229
+ if (allTerminal) {
230
+ break;
231
+ }
232
+ const remainingMs = deadlineMs - Date.now();
233
+ if (remainingMs <= 0) {
234
+ break;
235
+ }
236
+ await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs)));
237
+ }
238
+ const flushTs = new Date().toISOString();
239
+ for (const observer of observers) {
240
+ observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
241
+ observer.result.status = observer.process.status;
242
+ appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
243
+ appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
244
+ }
245
+ return {
246
+ peek_started_at: startedAt.toISOString(),
247
+ observed_duration_sec: observedDurationSec(startedAtMs),
248
+ processes,
249
+ };
250
+ }
177
251
  async killProcess(pid) {
178
252
  const process = this.readProcess(pid);
179
253
  const refreshed = this.refreshStatus(process);
@@ -341,6 +415,34 @@ export class CliProcessService {
341
415
  }
342
416
  return readFileSync(filePath, 'utf-8');
343
417
  }
418
+ fileSizeSafe(filePath) {
419
+ if (!existsSync(filePath)) {
420
+ return 0;
421
+ }
422
+ return statSync(filePath).size;
423
+ }
424
+ readTextFromOffset(filePath, offset) {
425
+ if (!existsSync(filePath)) {
426
+ return { text: '', offset };
427
+ }
428
+ const size = statSync(filePath).size;
429
+ if (size <= offset) {
430
+ return { text: '', offset: size };
431
+ }
432
+ const fd = openSync(filePath, 'r');
433
+ try {
434
+ const length = size - offset;
435
+ const buffer = Buffer.alloc(length);
436
+ const bytesRead = readSync(fd, buffer, 0, length, offset);
437
+ return {
438
+ text: buffer.subarray(0, bytesRead).toString('utf-8'),
439
+ offset: size,
440
+ };
441
+ }
442
+ finally {
443
+ closeSync(fd);
444
+ }
445
+ }
344
446
  resolveCwdsDir() {
345
447
  return join(this.stateDir, 'cwds');
346
448
  }
package/dist/parsers.js CHANGED
@@ -1,4 +1,102 @@
1
1
  import { debugLog } from './cli-utils.js';
2
+ function isGeminiAssistantMessageEvent(parsed) {
3
+ return parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string';
4
+ }
5
+ const GEMINI_STREAM_EVENT_TYPES = new Set([
6
+ 'init',
7
+ 'message',
8
+ 'tool_use',
9
+ 'tool_result',
10
+ 'result',
11
+ 'error',
12
+ 'stats',
13
+ ]);
14
+ function isGeminiStreamJsonEvent(parsed) {
15
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && GEMINI_STREAM_EVENT_TYPES.has(parsed.type);
16
+ }
17
+ function extractPeekMessagesFromParsedEvent(agent, parsed, observedAt) {
18
+ if (agent === 'codex') {
19
+ if (parsed.item?.type === 'agent_message' && typeof parsed.item.text === 'string' && parsed.item.text.trim()) {
20
+ return [{ ts: observedAt, text: parsed.item.text }];
21
+ }
22
+ if (parsed.msg?.type === 'agent_message' && typeof parsed.msg.message === 'string' && parsed.msg.message.trim()) {
23
+ return [{ ts: observedAt, text: parsed.msg.message }];
24
+ }
25
+ return [];
26
+ }
27
+ if (agent === 'claude' && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
28
+ return parsed.message.content
29
+ .filter((content) => content?.type === 'text' && typeof content.text === 'string' && content.text.trim())
30
+ .map((content) => ({ ts: observedAt, text: content.text }));
31
+ }
32
+ if (agent === 'opencode' && parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string' && parsed.part.text.trim()) {
33
+ return [{ ts: observedAt, text: parsed.part.text }];
34
+ }
35
+ return [];
36
+ }
37
+ export class PeekMessageExtractor {
38
+ agent;
39
+ pending = '';
40
+ geminiAssistantBuffer = '';
41
+ constructor(agent) {
42
+ this.agent = agent;
43
+ }
44
+ push(chunk, observedAt = new Date().toISOString()) {
45
+ if (!chunk) {
46
+ return [];
47
+ }
48
+ const lines = `${this.pending}${chunk}`.split(/\r?\n/);
49
+ this.pending = lines.pop() || '';
50
+ return this.extractLines(lines, observedAt);
51
+ }
52
+ flush(observedAt = new Date().toISOString()) {
53
+ const messages = [];
54
+ if (this.pending) {
55
+ const line = this.pending;
56
+ this.pending = '';
57
+ messages.push(...this.extractLines([line], observedAt));
58
+ }
59
+ messages.push(...this.flushGeminiAssistantBuffer(observedAt));
60
+ return messages;
61
+ }
62
+ extractLines(lines, observedAt) {
63
+ const messages = [];
64
+ for (const line of lines) {
65
+ if (!line.trim()) {
66
+ continue;
67
+ }
68
+ try {
69
+ messages.push(...this.extractParsedEvent(JSON.parse(line), observedAt));
70
+ }
71
+ catch {
72
+ debugLog(`[Debug] Skipping invalid peek JSON line: ${line}`);
73
+ messages.push(...this.flushGeminiAssistantBuffer(observedAt));
74
+ }
75
+ }
76
+ return messages;
77
+ }
78
+ extractParsedEvent(parsed, observedAt) {
79
+ if (this.agent !== 'gemini') {
80
+ return extractPeekMessagesFromParsedEvent(this.agent, parsed, observedAt);
81
+ }
82
+ if (isGeminiAssistantMessageEvent(parsed)) {
83
+ this.geminiAssistantBuffer += parsed.content;
84
+ return [];
85
+ }
86
+ return this.flushGeminiAssistantBuffer(observedAt);
87
+ }
88
+ flushGeminiAssistantBuffer(observedAt) {
89
+ if (this.agent !== 'gemini' || !this.geminiAssistantBuffer) {
90
+ return [];
91
+ }
92
+ const text = this.geminiAssistantBuffer;
93
+ this.geminiAssistantBuffer = '';
94
+ if (!text.trim()) {
95
+ return [];
96
+ }
97
+ return [{ ts: observedAt, text }];
98
+ }
99
+ }
2
100
  export function parseCodexOutput(stdout) {
3
101
  if (!stdout)
4
102
  return null;
@@ -137,12 +235,97 @@ export function parseGeminiOutput(stdout) {
137
235
  if (!stdout)
138
236
  return null;
139
237
  try {
140
- return JSON.parse(stdout);
238
+ const parsed = JSON.parse(stdout.trim());
239
+ if (!isGeminiStreamJsonEvent(parsed)) {
240
+ return parsed;
241
+ }
141
242
  }
142
243
  catch (e) {
143
244
  debugLog(`[Debug] Failed to parse Gemini JSON output: ${e}`);
144
- return null;
145
245
  }
246
+ let sessionId = null;
247
+ let assistantBuffer = '';
248
+ let lastMessage = null;
249
+ let stats = null;
250
+ const toolsById = new Map();
251
+ const toolsWithoutId = [];
252
+ const flushAssistantMessage = () => {
253
+ if (assistantBuffer.trim()) {
254
+ lastMessage = assistantBuffer;
255
+ }
256
+ assistantBuffer = '';
257
+ };
258
+ for (const line of stdout.split('\n')) {
259
+ if (!line.trim()) {
260
+ continue;
261
+ }
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(line);
265
+ }
266
+ catch (e) {
267
+ debugLog(`[Debug] Skipping invalid Gemini stream-json line: ${line}`);
268
+ flushAssistantMessage();
269
+ continue;
270
+ }
271
+ if (parsed.type === 'init' && typeof parsed.session_id === 'string' && parsed.session_id) {
272
+ sessionId = parsed.session_id;
273
+ continue;
274
+ }
275
+ if (isGeminiAssistantMessageEvent(parsed)) {
276
+ assistantBuffer += parsed.content;
277
+ continue;
278
+ }
279
+ flushAssistantMessage();
280
+ if (parsed.type === 'result') {
281
+ if (parsed.stats) {
282
+ stats = parsed.stats;
283
+ }
284
+ continue;
285
+ }
286
+ if (parsed.type === 'tool_use') {
287
+ const tool = {
288
+ tool: parsed.tool_name || parsed.name || 'tool_use',
289
+ input: parsed.parameters ?? parsed.input ?? null,
290
+ output: null,
291
+ status: null,
292
+ };
293
+ if (typeof parsed.tool_id === 'string' && parsed.tool_id) {
294
+ toolsById.set(parsed.tool_id, tool);
295
+ }
296
+ else {
297
+ toolsWithoutId.push(tool);
298
+ }
299
+ continue;
300
+ }
301
+ if (parsed.type === 'tool_result') {
302
+ const toolId = typeof parsed.tool_id === 'string' ? parsed.tool_id : '';
303
+ const tool = toolId ? toolsById.get(toolId) : null;
304
+ if (tool) {
305
+ tool.output = parsed.output ?? parsed.result ?? null;
306
+ tool.status = parsed.status ?? null;
307
+ }
308
+ else {
309
+ toolsWithoutId.push({
310
+ tool: 'tool_result',
311
+ input: null,
312
+ output: parsed.output ?? parsed.result ?? null,
313
+ status: parsed.status ?? null,
314
+ });
315
+ }
316
+ }
317
+ }
318
+ flushAssistantMessage();
319
+ const tools = [...toolsById.values(), ...toolsWithoutId];
320
+ if (lastMessage || sessionId || stats || tools.length > 0) {
321
+ return {
322
+ message: lastMessage,
323
+ session_id: sessionId,
324
+ stats: stats || undefined,
325
+ tools: tools.length > 0 ? tools : undefined,
326
+ };
327
+ }
328
+ return null;
146
329
  }
147
330
  export function parseForgeOutput(stdout) {
148
331
  if (!stdout)