ai-cli-mcp 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/peek.js ADDED
@@ -0,0 +1,56 @@
1
+ export const DEFAULT_PEEK_TIME_SEC = 10;
2
+ export const MAX_PEEK_TIME_SEC = 60;
3
+ export const MAX_PEEK_PIDS = 32;
4
+ export const PEEK_MESSAGE_CAP = 50;
5
+ export function validatePeekPids(value) {
6
+ if (!Array.isArray(value)) {
7
+ throw new Error('Missing or invalid required parameter: pids (must be an array of positive safe integers)');
8
+ }
9
+ const deduped = [];
10
+ const seen = new Set();
11
+ for (const pid of value) {
12
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) {
13
+ throw new Error('All pids must be positive safe integers');
14
+ }
15
+ if (!seen.has(pid)) {
16
+ seen.add(pid);
17
+ deduped.push(pid);
18
+ }
19
+ }
20
+ if (deduped.length === 0 || deduped.length > MAX_PEEK_PIDS) {
21
+ throw new Error(`pids must contain 1..${MAX_PEEK_PIDS} entries after dedupe`);
22
+ }
23
+ return deduped;
24
+ }
25
+ export function validatePeekTimeSec(value) {
26
+ if (value === undefined || value === null) {
27
+ return DEFAULT_PEEK_TIME_SEC;
28
+ }
29
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > MAX_PEEK_TIME_SEC) {
30
+ throw new Error(`peek_time_sec must be a positive integer no greater than ${MAX_PEEK_TIME_SEC}`);
31
+ }
32
+ return value;
33
+ }
34
+ export function buildNotFoundPeekProcess(pid) {
35
+ return {
36
+ pid,
37
+ agent: null,
38
+ status: 'not_found',
39
+ messages: [],
40
+ truncated: false,
41
+ error: 'process not found',
42
+ };
43
+ }
44
+ export function appendPeekMessages(target, messages) {
45
+ for (const message of messages) {
46
+ if (target.messages.length < PEEK_MESSAGE_CAP) {
47
+ target.messages.push(message);
48
+ }
49
+ else {
50
+ target.truncated = true;
51
+ }
52
+ }
53
+ }
54
+ export function observedDurationSec(startedAtMs, endedAtMs = Date.now()) {
55
+ return Number(((endedAtMs - startedAtMs) / 1000).toFixed(2));
56
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { buildCliCommand } from './cli-builder.js';
3
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
3
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
4
+ import { appendPeekMessages, buildNotFoundPeekProcess, observedDurationSec, validatePeekPids, validatePeekTimeSec, } from './peek.js';
4
5
  import { buildProcessResult } from './process-result.js';
5
6
  function parseAgentOutput(agent, stdout, stderr) {
6
7
  if (agent === 'codex') {
@@ -155,6 +156,85 @@ export class ProcessService {
155
156
  }
156
157
  }
157
158
  }
159
+ async peekProcesses(pids, peekTimeSec = 10) {
160
+ const targetPids = validatePeekPids(pids);
161
+ const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
162
+ const processes = [];
163
+ const observers = [];
164
+ for (const pid of targetPids) {
165
+ const entry = this.processManager.get(pid);
166
+ if (!entry) {
167
+ processes.push(buildNotFoundPeekProcess(pid));
168
+ continue;
169
+ }
170
+ const result = {
171
+ pid,
172
+ agent: entry.toolType,
173
+ status: entry.status,
174
+ messages: [],
175
+ truncated: false,
176
+ error: null,
177
+ };
178
+ processes.push(result);
179
+ const stdoutExtractor = new PeekMessageExtractor(entry.toolType);
180
+ const stderrExtractor = new PeekMessageExtractor(entry.toolType);
181
+ const onStdout = (data) => {
182
+ appendPeekMessages(result, stdoutExtractor.push(data.toString(), new Date().toISOString()));
183
+ };
184
+ const onStderr = (data) => {
185
+ appendPeekMessages(result, stderrExtractor.push(data.toString(), new Date().toISOString()));
186
+ };
187
+ if (entry.status === 'running') {
188
+ entry.process.stdout?.on('data', onStdout);
189
+ entry.process.stderr?.on('data', onStderr);
190
+ }
191
+ observers.push({ entry, result, stdoutExtractor, stderrExtractor, onStdout, onStderr });
192
+ }
193
+ const startedAt = new Date();
194
+ const startedAtMs = Date.now();
195
+ const runningObservers = observers.filter((observer) => observer.entry.status === 'running');
196
+ const terminalPromise = Promise.all(runningObservers.map((observer) => this.waitForProcessTerminal(observer.entry)));
197
+ let timeoutHandle;
198
+ const timeoutPromise = new Promise((resolve) => {
199
+ timeoutHandle = setTimeout(resolve, targetPeekTimeSec * 1000);
200
+ timeoutHandle.unref?.();
201
+ });
202
+ try {
203
+ await Promise.race([terminalPromise, timeoutPromise]);
204
+ }
205
+ finally {
206
+ if (timeoutHandle) {
207
+ clearTimeout(timeoutHandle);
208
+ }
209
+ const flushTs = new Date().toISOString();
210
+ for (const observer of observers) {
211
+ observer.entry.process.stdout?.off('data', observer.onStdout);
212
+ observer.entry.process.stderr?.off('data', observer.onStderr);
213
+ appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
214
+ appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
215
+ observer.result.status = observer.entry.status;
216
+ }
217
+ }
218
+ return {
219
+ peek_started_at: startedAt.toISOString(),
220
+ observed_duration_sec: observedDurationSec(startedAtMs),
221
+ processes,
222
+ };
223
+ }
224
+ waitForProcessTerminal(processEntry) {
225
+ if (processEntry.status !== 'running') {
226
+ return Promise.resolve();
227
+ }
228
+ return new Promise((resolve) => {
229
+ const done = () => {
230
+ processEntry.process.off('close', done);
231
+ processEntry.process.off('error', done);
232
+ resolve();
233
+ };
234
+ processEntry.process.once('close', done);
235
+ processEntry.process.once('error', done);
236
+ });
237
+ }
158
238
  killProcess(pid) {
159
239
  const processEntry = this.processManager.get(pid);
160
240
  if (!processEntry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-mcp",
3
- "version": "2.15.0",
3
+ "version": "2.16.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",
@@ -3,6 +3,7 @@ import {
3
3
  CLI_HELP_TEXT,
4
4
  DOCTOR_HELP_TEXT,
5
5
  MODELS_HELP_TEXT,
6
+ PEEK_HELP_TEXT,
6
7
  RESULT_HELP_TEXT,
7
8
  RUN_HELP_TEXT,
8
9
  WAIT_HELP_TEXT,
@@ -199,6 +200,64 @@ describe('ai-cli app', () => {
199
200
  expect(waitForProcesses).not.toHaveBeenCalled();
200
201
  });
201
202
 
203
+ it('dispatches peek with deduped pid arguments and time', async () => {
204
+ const stdout = vi.fn();
205
+ const stderr = vi.fn();
206
+ const peekProcesses = vi.fn().mockResolvedValue({
207
+ peek_started_at: '2026-04-11T12:34:56.789Z',
208
+ observed_duration_sec: 0.01,
209
+ processes: [],
210
+ });
211
+
212
+ const exitCode = await runCli(
213
+ ['peek', '123', '456', '123', '--time', '5'],
214
+ {
215
+ stdout,
216
+ stderr,
217
+ peekProcesses,
218
+ }
219
+ );
220
+
221
+ expect(exitCode).toBe(0);
222
+ expect(peekProcesses).toHaveBeenCalledWith([123, 456], 5);
223
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"peek_started_at"'));
224
+ expect(stderr).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('defaults peek time and rejects --follow', async () => {
228
+ const stdout = vi.fn();
229
+ const stderr = vi.fn();
230
+ const peekProcesses = vi.fn().mockResolvedValue({
231
+ peek_started_at: '2026-04-11T12:34:56.789Z',
232
+ observed_duration_sec: 0.01,
233
+ processes: [],
234
+ });
235
+
236
+ const defaultExitCode = await runCli(['peek', '123'], { stdout, stderr, peekProcesses });
237
+ expect(defaultExitCode).toBe(0);
238
+ expect(peekProcesses).toHaveBeenCalledWith([123], 10);
239
+
240
+ const followExitCode = await runCli(['peek', '123', '--follow'], { stdout, stderr, peekProcesses });
241
+ expect(followExitCode).toBe(1);
242
+ expect(stderr).toHaveBeenCalledWith('peek does not support --follow in v1\n');
243
+ });
244
+
245
+ it('rejects invalid peek time values', async () => {
246
+ const stdout = vi.fn();
247
+ const stderr = vi.fn();
248
+ const peekProcesses = vi.fn();
249
+
250
+ const exitCode = await runCli(['peek', '123', '--time', '1.5'], {
251
+ stdout,
252
+ stderr,
253
+ peekProcesses,
254
+ });
255
+
256
+ expect(exitCode).toBe(1);
257
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('peek_time_sec must be a positive integer'));
258
+ expect(peekProcesses).not.toHaveBeenCalled();
259
+ });
260
+
202
261
  it('dispatches ps, result, and kill', async () => {
203
262
  const stdout = vi.fn();
204
263
  const stderr = vi.fn();
@@ -354,6 +413,18 @@ describe('ai-cli app', () => {
354
413
  expect(stderr).not.toHaveBeenCalled();
355
414
  });
356
415
 
416
+ it('prints detailed help for peek --help', async () => {
417
+ const stdout = vi.fn();
418
+ const stderr = vi.fn();
419
+
420
+ const exitCode = await runCli(['peek', '--help'], { stdout, stderr });
421
+
422
+ expect(exitCode).toBe(0);
423
+ expect(stdout).toHaveBeenCalledWith(PEEK_HELP_TEXT);
424
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('No --follow mode'));
425
+ expect(stderr).not.toHaveBeenCalled();
426
+ });
427
+
357
428
  it('prints detailed help for models --help', async () => {
358
429
  const stdout = vi.fn();
359
430
  const stderr = vi.fn();
@@ -388,7 +388,7 @@ describe('cli-builder', () => {
388
388
  expect(cmd.cliPath).toBe('/usr/bin/gemini');
389
389
  expect(cmd.args).toContain('-y');
390
390
  expect(cmd.args).toContain('--output-format');
391
- expect(cmd.args).toContain('json');
391
+ expect(cmd.args).toContain('stream-json');
392
392
  expect(cmd.args).toContain('--model');
393
393
  expect(cmd.args).toContain('gemini-2.5-pro');
394
394
  });
@@ -122,6 +122,74 @@ describe('CliProcessService', () => {
122
122
  expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
123
123
  });
124
124
 
125
+ it('peeks only appended natural-language messages from detached logs', async () => {
126
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
127
+ tempDirs.push(root);
128
+ const scriptPath = join(root, 'mock-claude-peek');
129
+ writeFileSync(
130
+ scriptPath,
131
+ `#!/bin/bash
132
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
133
+ sleep 2
134
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"new cli message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}'
135
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
136
+ `
137
+ );
138
+ chmodSync(scriptPath, 0o755);
139
+ const stateDir = join(root, 'state');
140
+ const workFolder = join(root, 'work');
141
+ mkdirSync(workFolder, { recursive: true });
142
+
143
+ const service = new CliProcessService({
144
+ stateDir,
145
+ cliPaths: {
146
+ claude: scriptPath,
147
+ codex: scriptPath,
148
+ gemini: scriptPath,
149
+ forge: scriptPath,
150
+ opencode: scriptPath,
151
+ },
152
+ });
153
+
154
+ const runResult = await service.startProcess({
155
+ prompt: 'hello peek',
156
+ cwd: workFolder,
157
+ });
158
+
159
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
160
+ const stdoutPath = join(processDir, 'stdout.log');
161
+ const startedAt = Date.now();
162
+ while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
163
+ await new Promise((resolve) => setTimeout(resolve, 25));
164
+ }
165
+ expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
166
+
167
+ const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
168
+
169
+ expect(peekResult.processes).toHaveLength(2);
170
+ expect(peekResult.processes[0]).toMatchObject({
171
+ pid: runResult.pid,
172
+ agent: 'claude',
173
+ status: 'completed',
174
+ messages: [
175
+ {
176
+ ts: expect.any(String),
177
+ text: 'new cli message',
178
+ },
179
+ ],
180
+ truncated: false,
181
+ error: null,
182
+ });
183
+ expect(peekResult.processes[1]).toEqual({
184
+ pid: 999999,
185
+ agent: null,
186
+ status: 'not_found',
187
+ messages: [],
188
+ truncated: false,
189
+ error: 'process not found',
190
+ });
191
+ });
192
+
125
193
  it('returns compact results by default and full results when verbose is true', async () => {
126
194
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
127
195
  tempDirs.push(root);
@@ -39,7 +39,7 @@ describe('Claude Code MCP E2E Tests', () => {
39
39
  it('should register run tool', async () => {
40
40
  const tools = await client.listTools();
41
41
 
42
- expect(tools).toHaveLength(6);
42
+ expect(tools).toHaveLength(7);
43
43
  const claudeCodeTool = tools.find((t: any) => t.name === 'run');
44
44
  expect(claudeCodeTool.inputSchema.properties.model.description).toContain('sonnet');
45
45
  expect(claudeCodeTool.inputSchema.properties.model.description).toContain('opencode');
@@ -49,6 +49,7 @@ describe('Claude Code MCP E2E Tests', () => {
49
49
  // Verify other tools exist
50
50
  expect(tools.some((t: any) => t.name === 'list_processes')).toBe(true);
51
51
  expect(tools.some((t: any) => t.name === 'get_result')).toBe(true);
52
+ expect(tools.some((t: any) => t.name === 'peek')).toBe(true);
52
53
  expect(tools.some((t: any) => t.name === 'kill_process')).toBe(true);
53
54
  });
54
55
  });
@@ -96,6 +96,7 @@ describe('MCP Contract Tests', () => {
96
96
  'get_result',
97
97
  'kill_process',
98
98
  'list_processes',
99
+ 'peek',
99
100
  'run',
100
101
  'wait',
101
102
  ]);
@@ -132,6 +133,14 @@ describe('MCP Contract Tests', () => {
132
133
  'timeout',
133
134
  'verbose',
134
135
  ]);
136
+
137
+ const peekTool = tools.find((tool: any) => tool.name === 'peek');
138
+ expect(peekTool.inputSchema.required).toEqual(['pids']);
139
+ expect(Object.keys(peekTool.inputSchema.properties).sort()).toEqual([
140
+ 'peek_time_sec',
141
+ 'pids',
142
+ ]);
143
+ expect(peekTool.description).toContain('One-shot');
135
144
  });
136
145
 
137
146
  it('preserves the stdio MCP smoke flow and response shapes', async () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseOpenCodeOutput } from '../parsers.js';
2
+ import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from '../parsers.js';
3
3
 
4
4
  describe('parseCodexOutput', () => {
5
5
  it('should parse basic Codex output with message and session_id', () => {
@@ -65,6 +65,193 @@ INVALID_JSON
65
65
  });
66
66
  });
67
67
 
68
+ describe('PeekMessageExtractor', () => {
69
+ const ts = '2026-04-11T12:34:56.789Z';
70
+
71
+ it('extracts only Codex agent_message text', () => {
72
+ const extractor = new PeekMessageExtractor('codex');
73
+ const output = [
74
+ '{"type":"item.completed","item":{"type":"reasoning","text":"hidden"}}',
75
+ '{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"secret command output"}}',
76
+ '{"type":"item.completed","item":{"type":"agent_message","text":"Visible Codex message"}}',
77
+ '{"msg":{"type":"token_count","total":123}}',
78
+ '{"msg":{"type":"agent_message","message":"Visible legacy Codex message"}}',
79
+ ].join('\n') + '\n';
80
+
81
+ expect(extractor.push(output, ts)).toEqual([
82
+ { ts, text: 'Visible Codex message' },
83
+ { ts, text: 'Visible legacy Codex message' },
84
+ ]);
85
+ });
86
+
87
+ it('extracts only Claude assistant text content', () => {
88
+ const extractor = new PeekMessageExtractor('claude');
89
+ const output = [
90
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"Visible Claude text"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}',
91
+ '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}',
92
+ '{"type":"result","result":"Final result is not peek assistant text"}',
93
+ ].join('\n') + '\n';
94
+
95
+ expect(extractor.push(output, ts)).toEqual([
96
+ { ts, text: 'Visible Claude text' },
97
+ ]);
98
+ });
99
+
100
+ it('extracts only Gemini assistant message content', () => {
101
+ const extractor = new PeekMessageExtractor('gemini');
102
+ const output = [
103
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
104
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}',
105
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
106
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
107
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result is not peek assistant text"}',
108
+ ].join('\n') + '\n';
109
+
110
+ expect(extractor.push(output, ts)).toEqual([
111
+ { ts, text: 'Visible Gemini text' },
112
+ ]);
113
+ });
114
+
115
+ it('joins split Gemini assistant chunks into one peek message on flush', () => {
116
+ const extractor = new PeekMessageExtractor('gemini');
117
+ const output = [
118
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Step 2 done. Starting step ","delta":true}',
119
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"3.","delta":true}',
120
+ ].join('\n') + '\n';
121
+
122
+ expect(extractor.push(output, ts)).toEqual([]);
123
+ expect(extractor.flush('2026-04-11T12:34:59.000Z')).toEqual([
124
+ { ts: '2026-04-11T12:34:59.000Z', text: 'Step 2 done. Starting step 3.' },
125
+ ]);
126
+ });
127
+
128
+ it('emits separate Gemini peek messages when a boundary separates logical messages', () => {
129
+ const extractor = new PeekMessageExtractor('gemini');
130
+ const output = [
131
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Starting step ","delta":true}',
132
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"1.","delta":true}',
133
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.822Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
134
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
135
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final ","delta":true}',
136
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"answer.","delta":true}',
137
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
138
+ ].join('\n') + '\n';
139
+
140
+ expect(extractor.push(output, ts)).toEqual([
141
+ { ts, text: 'Starting step 1.' },
142
+ { ts, text: 'Final answer.' },
143
+ ]);
144
+ expect(extractor.flush(ts)).toEqual([]);
145
+ });
146
+
147
+ it('does not emit Gemini user, tool, tool result, stats, or result response text', () => {
148
+ const extractor = new PeekMessageExtractor('gemini');
149
+ const output = [
150
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
151
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
152
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
153
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
154
+ ].join('\n') + '\n';
155
+
156
+ expect(extractor.push(output, ts)).toEqual([]);
157
+ expect(extractor.flush(ts)).toEqual([]);
158
+ });
159
+
160
+ it('denies unsupported agents and invalid shapes by default', () => {
161
+ const extractor = new PeekMessageExtractor('forge');
162
+ const output = [
163
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"not supported here"}]}}',
164
+ '{"type":"item.completed","item":{"type":"agent_message","text":"not supported here"}}',
165
+ '{"type":"text","part":{"type":"text","text":"not supported here"}}',
166
+ '{"type":"message","role":"assistant","content":"not supported here"}',
167
+ 'plain stdout',
168
+ ].join('\n') + '\n';
169
+
170
+ expect(extractor.push(output, ts)).toEqual([]);
171
+ });
172
+
173
+ it('extracts only OpenCode natural-language text events', () => {
174
+ const extractor = new PeekMessageExtractor('opencode');
175
+ const output = [
176
+ '{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}',
177
+ '{"type":"tool_use","timestamp":1775918783606,"sessionID":"ses-1","part":{"type":"tool","state":{"output":"secret command output"},"metadata":{"output":"secret metadata output"}}}',
178
+ '{"type":"text","timestamp":1775918783607,"sessionID":"ses-1","part":{"type":"tool","text":"wrong part type"}}',
179
+ ].join('\n') + '\n';
180
+
181
+ expect(extractor.push(output, ts)).toEqual([
182
+ { ts, text: 'OpenCode visible text' },
183
+ ]);
184
+ });
185
+
186
+ it('can flush a complete JSON event without a trailing newline', () => {
187
+ const extractor = new PeekMessageExtractor('codex');
188
+ expect(extractor.push('{"type":"item.completed","item":{"type":"agent_message","text":"pending"}}', ts)).toEqual([]);
189
+ expect(extractor.flush(ts)).toEqual([{ ts, text: 'pending' }]);
190
+ });
191
+ });
192
+
193
+ describe('parseGeminiOutput', () => {
194
+ it('should parse legacy final JSON output', () => {
195
+ const output = JSON.stringify({
196
+ session_id: 'gemini-session-json',
197
+ response: 'Legacy Gemini final response',
198
+ stats: {
199
+ total_tokens: 123,
200
+ },
201
+ });
202
+
203
+ expect(parseGeminiOutput(output)).toEqual({
204
+ session_id: 'gemini-session-json',
205
+ response: 'Legacy Gemini final response',
206
+ stats: {
207
+ total_tokens: 123,
208
+ },
209
+ });
210
+ });
211
+
212
+ it('should normalize a single-line Gemini assistant stream event', () => {
213
+ const output = '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Only answer","delta":true}';
214
+
215
+ const result = parseGeminiOutput(output);
216
+
217
+ expect(result).toMatchObject({
218
+ message: 'Only answer',
219
+ session_id: null,
220
+ });
221
+ expect(result).not.toHaveProperty('type');
222
+ expect(result).not.toHaveProperty('content');
223
+ });
224
+
225
+ it('should parse Gemini stream-json NDJSON output', () => {
226
+ const output = [
227
+ '{"type":"init","timestamp":"2026-04-11T14:44:42.293Z","session_id":"gemini-session-stream","model":"gemini-3.1-pro-preview"}',
228
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
229
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"First logical assistant response.","delta":true}',
230
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"echo hidden"}}',
231
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","tool_id":"tool-1","status":"success","output":"hidden command output"}',
232
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final assistant ","delta":true}',
233
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"response.","delta":true}',
234
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Result response is not the parsed message","stats":{"total_tokens":21999}}',
235
+ ].join('\n') + '\n';
236
+
237
+ expect(parseGeminiOutput(output)).toEqual({
238
+ message: 'Final assistant response.',
239
+ session_id: 'gemini-session-stream',
240
+ stats: {
241
+ total_tokens: 21999,
242
+ },
243
+ tools: [
244
+ {
245
+ tool: 'run_shell_command',
246
+ input: { command: 'echo hidden' },
247
+ output: 'hidden command output',
248
+ status: 'success',
249
+ },
250
+ ],
251
+ });
252
+ });
253
+ });
254
+
68
255
  describe('parseClaudeOutput', () => {
69
256
  it('should parse legacy JSON output', () => {
70
257
  const output = JSON.stringify({
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { appendPeekMessages, validatePeekPids, validatePeekTimeSec, type PeekProcessResult } from '../peek.js';
3
+
4
+ describe('peek helpers', () => {
5
+ it('dedupes pids while preserving first occurrence order', () => {
6
+ expect(validatePeekPids([3, 1, 3, 2, 1])).toEqual([3, 1, 2]);
7
+ });
8
+
9
+ it('validates pid and time limits', () => {
10
+ expect(() => validatePeekPids([])).toThrow(/1..32/);
11
+ expect(() => validatePeekPids([1.5])).toThrow(/positive safe integers/);
12
+ expect(() => validatePeekPids([Number.MAX_SAFE_INTEGER + 1])).toThrow(/positive safe integers/);
13
+ expect(validatePeekTimeSec(undefined)).toBe(10);
14
+ expect(validatePeekTimeSec(60)).toBe(60);
15
+ expect(() => validatePeekTimeSec(0)).toThrow(/positive integer/);
16
+ expect(() => validatePeekTimeSec(1.5)).toThrow(/positive integer/);
17
+ expect(() => validatePeekTimeSec(61)).toThrow(/positive integer/);
18
+ });
19
+
20
+ it('keeps the first 50 messages and marks truncation when later messages are dropped', () => {
21
+ const process: PeekProcessResult = {
22
+ pid: 123,
23
+ agent: 'codex',
24
+ status: 'running',
25
+ messages: [],
26
+ truncated: false,
27
+ error: null,
28
+ };
29
+
30
+ appendPeekMessages(
31
+ process,
32
+ Array.from({ length: 55 }, (_, index) => ({
33
+ ts: '2026-04-11T12:34:56.789Z',
34
+ text: `message ${index}`,
35
+ })),
36
+ );
37
+
38
+ expect(process.messages).toHaveLength(50);
39
+ expect(process.messages[0].text).toBe('message 0');
40
+ expect(process.messages[49].text).toBe('message 49');
41
+ expect(process.truncated).toBe(true);
42
+ });
43
+ });