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/CHANGELOG.md +7 -0
- package/README.ja.md +58 -0
- package/README.md +58 -0
- package/dist/__tests__/app-cli.test.js +56 -1
- package/dist/__tests__/cli-builder.test.js +1 -1
- package/dist/__tests__/cli-process-service.test.js +59 -0
- package/dist/__tests__/e2e.test.js +2 -1
- package/dist/__tests__/mcp-contract.test.js +8 -0
- package/dist/__tests__/parsers.test.js +163 -1
- package/dist/__tests__/peek.test.js +35 -0
- package/dist/__tests__/process-management.test.js +159 -0
- package/dist/__tests__/server.test.js +4 -3
- package/dist/app/cli.js +43 -1
- package/dist/app/mcp.js +45 -0
- package/dist/cli-builder.js +1 -1
- package/dist/cli-process-service.js +104 -2
- package/dist/parsers.js +185 -2
- package/dist/peek.js +56 -0
- package/dist/process-service.js +81 -1
- package/package.json +1 -1
- package/src/__tests__/app-cli.test.ts +71 -0
- package/src/__tests__/cli-builder.test.ts +1 -1
- package/src/__tests__/cli-process-service.test.ts +68 -0
- package/src/__tests__/e2e.test.ts +2 -1
- package/src/__tests__/mcp-contract.test.ts +9 -0
- package/src/__tests__/parsers.test.ts +188 -1
- package/src/__tests__/peek.test.ts +43 -0
- package/src/__tests__/process-management.test.ts +184 -0
- package/src/__tests__/server.test.ts +4 -3
- package/src/app/cli.ts +48 -0
- package/src/app/mcp.ts +46 -0
- package/src/cli-builder.ts +1 -1
- package/src/cli-process-service.ts +134 -1
- package/src/parsers.ts +222 -2
- package/src/peek.ts +88 -0
- package/src/process-service.ts +107 -1
|
@@ -118,6 +118,190 @@ describe('Process Management Tests', () => {
|
|
|
118
118
|
expect(response.message).toBe('claude process started successfully');
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
it('should peek only natural-language messages observed after registration', async () => {
|
|
122
|
+
const { handlers } = await setupServer();
|
|
123
|
+
|
|
124
|
+
const mockProcess = new EventEmitter() as any;
|
|
125
|
+
mockProcess.pid = 12345;
|
|
126
|
+
mockProcess.stdout = new EventEmitter();
|
|
127
|
+
mockProcess.stderr = new EventEmitter();
|
|
128
|
+
mockProcess.kill = vi.fn();
|
|
129
|
+
|
|
130
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
131
|
+
|
|
132
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
133
|
+
await callToolHandler!({
|
|
134
|
+
params: {
|
|
135
|
+
name: 'run',
|
|
136
|
+
arguments: {
|
|
137
|
+
prompt: 'test prompt',
|
|
138
|
+
workFolder: '/tmp'
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"old message"}]}}\n');
|
|
144
|
+
|
|
145
|
+
const peekPromise = callToolHandler!({
|
|
146
|
+
params: {
|
|
147
|
+
name: 'peek',
|
|
148
|
+
arguments: {
|
|
149
|
+
pids: [12345, 12345, 99999],
|
|
150
|
+
peek_time_sec: 1,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
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');
|
|
157
|
+
mockProcess.stdout.emit('data', '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}\n');
|
|
158
|
+
mockProcess.emit('close', 0);
|
|
159
|
+
}, 10);
|
|
160
|
+
|
|
161
|
+
const result = await peekPromise;
|
|
162
|
+
const response = JSON.parse(result.content[0].text);
|
|
163
|
+
|
|
164
|
+
expect(response.processes).toHaveLength(2);
|
|
165
|
+
expect(response.processes[0]).toMatchObject({
|
|
166
|
+
pid: 12345,
|
|
167
|
+
agent: 'claude',
|
|
168
|
+
status: 'completed',
|
|
169
|
+
messages: [
|
|
170
|
+
{
|
|
171
|
+
ts: expect.any(String),
|
|
172
|
+
text: 'new message',
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
truncated: false,
|
|
176
|
+
error: null,
|
|
177
|
+
});
|
|
178
|
+
expect(response.processes[1]).toEqual({
|
|
179
|
+
pid: 99999,
|
|
180
|
+
agent: null,
|
|
181
|
+
status: 'not_found',
|
|
182
|
+
messages: [],
|
|
183
|
+
truncated: false,
|
|
184
|
+
error: 'process not found',
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should peek OpenCode text events and exclude OpenCode tool output', async () => {
|
|
189
|
+
const { handlers } = await setupServer();
|
|
190
|
+
|
|
191
|
+
const mockProcess = new EventEmitter() as any;
|
|
192
|
+
mockProcess.pid = 12346;
|
|
193
|
+
mockProcess.stdout = new EventEmitter();
|
|
194
|
+
mockProcess.stderr = new EventEmitter();
|
|
195
|
+
mockProcess.kill = vi.fn();
|
|
196
|
+
|
|
197
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
198
|
+
|
|
199
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
200
|
+
await callToolHandler!({
|
|
201
|
+
params: {
|
|
202
|
+
name: 'run',
|
|
203
|
+
arguments: {
|
|
204
|
+
prompt: 'opencode peek prompt',
|
|
205
|
+
workFolder: '/tmp',
|
|
206
|
+
model: 'opencode',
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const peekPromise = callToolHandler!({
|
|
212
|
+
params: {
|
|
213
|
+
name: 'peek',
|
|
214
|
+
arguments: {
|
|
215
|
+
pids: [12346],
|
|
216
|
+
peek_time_sec: 1,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
setTimeout(() => {
|
|
222
|
+
mockProcess.stdout.emit('data', '{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}\n');
|
|
223
|
+
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');
|
|
224
|
+
mockProcess.emit('close', 0);
|
|
225
|
+
}, 10);
|
|
226
|
+
|
|
227
|
+
const result = await peekPromise;
|
|
228
|
+
const response = JSON.parse(result.content[0].text);
|
|
229
|
+
|
|
230
|
+
expect(response.processes).toHaveLength(1);
|
|
231
|
+
expect(response.processes[0]).toMatchObject({
|
|
232
|
+
pid: 12346,
|
|
233
|
+
agent: 'opencode',
|
|
234
|
+
status: 'completed',
|
|
235
|
+
messages: [
|
|
236
|
+
{
|
|
237
|
+
ts: expect.any(String),
|
|
238
|
+
text: 'OpenCode visible text',
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
truncated: false,
|
|
242
|
+
error: null,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should peek Gemini assistant message events and exclude tool output', async () => {
|
|
247
|
+
const { handlers } = await setupServer();
|
|
248
|
+
|
|
249
|
+
const mockProcess = new EventEmitter() as any;
|
|
250
|
+
mockProcess.pid = 12347;
|
|
251
|
+
mockProcess.stdout = new EventEmitter();
|
|
252
|
+
mockProcess.stderr = new EventEmitter();
|
|
253
|
+
mockProcess.kill = vi.fn();
|
|
254
|
+
|
|
255
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
256
|
+
|
|
257
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
258
|
+
await callToolHandler!({
|
|
259
|
+
params: {
|
|
260
|
+
name: 'run',
|
|
261
|
+
arguments: {
|
|
262
|
+
prompt: 'gemini peek prompt',
|
|
263
|
+
workFolder: '/tmp',
|
|
264
|
+
model: 'gemini-2.5-pro',
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const peekPromise = callToolHandler!({
|
|
270
|
+
params: {
|
|
271
|
+
name: 'peek',
|
|
272
|
+
arguments: {
|
|
273
|
+
pids: [12347],
|
|
274
|
+
peek_time_sec: 1,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}\n');
|
|
281
|
+
mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}\n');
|
|
282
|
+
mockProcess.stdout.emit('data', '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}\n');
|
|
283
|
+
mockProcess.emit('close', 0);
|
|
284
|
+
}, 10);
|
|
285
|
+
|
|
286
|
+
const result = await peekPromise;
|
|
287
|
+
const response = JSON.parse(result.content[0].text);
|
|
288
|
+
|
|
289
|
+
expect(response.processes).toHaveLength(1);
|
|
290
|
+
expect(response.processes[0]).toMatchObject({
|
|
291
|
+
pid: 12347,
|
|
292
|
+
agent: 'gemini',
|
|
293
|
+
status: 'completed',
|
|
294
|
+
messages: [
|
|
295
|
+
{
|
|
296
|
+
ts: expect.any(String),
|
|
297
|
+
text: 'Visible Gemini text',
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
truncated: false,
|
|
301
|
+
error: null,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
121
305
|
it('should handle process with model parameter', async () => {
|
|
122
306
|
const { handlers } = await setupServer();
|
|
123
307
|
|
|
@@ -497,14 +497,15 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
497
497
|
const handler = listToolsCall[1];
|
|
498
498
|
const result = await handler();
|
|
499
499
|
|
|
500
|
-
expect(result.tools).toHaveLength(
|
|
500
|
+
expect(result.tools).toHaveLength(7);
|
|
501
501
|
expect(result.tools[0].name).toBe('run');
|
|
502
502
|
expect(result.tools[0].description).toContain('AI Agent Runner');
|
|
503
503
|
expect(result.tools[1].name).toBe('list_processes');
|
|
504
504
|
expect(result.tools[2].name).toBe('get_result');
|
|
505
505
|
expect(result.tools[3].name).toBe('wait');
|
|
506
|
-
expect(result.tools[4].name).toBe('
|
|
507
|
-
expect(result.tools[5].name).toBe('
|
|
506
|
+
expect(result.tools[4].name).toBe('peek');
|
|
507
|
+
expect(result.tools[5].name).toBe('kill_process');
|
|
508
|
+
expect(result.tools[6].name).toBe('cleanup_processes');
|
|
508
509
|
});
|
|
509
510
|
|
|
510
511
|
it('should handle CallToolRequest', async () => {
|
package/src/app/cli.ts
CHANGED
|
@@ -2,12 +2,14 @@ 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
|
|
|
6
7
|
export const CLI_HELP_TEXT = `Usage: ai-cli <command> [options]
|
|
7
8
|
|
|
8
9
|
Commands:
|
|
9
10
|
run Start an AI CLI process in the background
|
|
10
11
|
wait Wait for one or more pids
|
|
12
|
+
peek Observe new natural-language agent messages for a short window
|
|
11
13
|
ps List tracked processes
|
|
12
14
|
result Get the current result for a pid
|
|
13
15
|
kill Terminate a tracked pid
|
|
@@ -58,6 +60,17 @@ Options:
|
|
|
58
60
|
--help, -h Show this help message
|
|
59
61
|
`;
|
|
60
62
|
|
|
63
|
+
export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
|
|
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: [].
|
|
67
|
+
This is not a history API, gapless streaming, or stdout/stderr tailing. No --follow mode is available in v1.
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--time <seconds> Observation window in seconds. Defaults to 10, maximum 60
|
|
71
|
+
--help, -h Show this help message
|
|
72
|
+
`;
|
|
73
|
+
|
|
61
74
|
export const KILL_HELP_TEXT = `Usage: ai-cli kill <pid>
|
|
62
75
|
|
|
63
76
|
Terminate a tracked process.
|
|
@@ -118,6 +131,7 @@ interface CliDeps {
|
|
|
118
131
|
listProcesses: () => Promise<any>;
|
|
119
132
|
getProcessResult: (pid: number, verbose: boolean) => Promise<any>;
|
|
120
133
|
waitForProcesses: (pids: number[], timeoutSeconds?: number, verbose?: boolean) => Promise<any>;
|
|
134
|
+
peekProcesses: (pids: number[], peekTimeSec?: number) => Promise<any>;
|
|
121
135
|
killProcess: (pid: number) => Promise<any>;
|
|
122
136
|
cleanupProcesses: () => Promise<any>;
|
|
123
137
|
getDoctorStatus: () => any;
|
|
@@ -140,6 +154,7 @@ const defaultDeps: CliDeps = {
|
|
|
140
154
|
listProcesses: () => getCliProcessService().listProcesses(),
|
|
141
155
|
getProcessResult: (pid, verbose) => getCliProcessService().getProcessResult(pid, verbose),
|
|
142
156
|
waitForProcesses: (pids, timeoutSeconds, verbose) => getCliProcessService().waitForProcesses(pids, timeoutSeconds, verbose),
|
|
157
|
+
peekProcesses: (pids, peekTimeSec) => getCliProcessService().peekProcesses(pids, peekTimeSec),
|
|
143
158
|
killProcess: (pid) => getCliProcessService().killProcess(pid),
|
|
144
159
|
cleanupProcesses: () => getCliProcessService().cleanupProcesses(),
|
|
145
160
|
getDoctorStatus: () => getCliDoctorStatus(),
|
|
@@ -204,6 +219,10 @@ function hasHelpFlag(flags: Record<string, string>): boolean {
|
|
|
204
219
|
return 'help' in flags || 'h' in flags;
|
|
205
220
|
}
|
|
206
221
|
|
|
222
|
+
function parsePeekCliPids(values: string[]): number[] {
|
|
223
|
+
return validatePeekPids(values.map((value) => Number(value)));
|
|
224
|
+
}
|
|
225
|
+
|
|
207
226
|
export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promise<number> {
|
|
208
227
|
const {
|
|
209
228
|
stdout,
|
|
@@ -213,6 +232,7 @@ export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promi
|
|
|
213
232
|
listProcesses,
|
|
214
233
|
getProcessResult,
|
|
215
234
|
waitForProcesses,
|
|
235
|
+
peekProcesses,
|
|
216
236
|
killProcess,
|
|
217
237
|
cleanupProcesses,
|
|
218
238
|
getDoctorStatus,
|
|
@@ -323,6 +343,34 @@ export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promi
|
|
|
323
343
|
return 0;
|
|
324
344
|
}
|
|
325
345
|
|
|
346
|
+
if (command === 'peek') {
|
|
347
|
+
const { positionals, flags } = parseArgs(argv.slice(1));
|
|
348
|
+
if (hasHelpFlag(flags)) {
|
|
349
|
+
stdout(PEEK_HELP_TEXT);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if ('follow' in flags) {
|
|
353
|
+
stderr('peek does not support --follow in v1\n');
|
|
354
|
+
stdout(CLI_HELP_TEXT);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let pids: number[];
|
|
359
|
+
let peekTimeSec: number;
|
|
360
|
+
try {
|
|
361
|
+
pids = parsePeekCliPids(positionals);
|
|
362
|
+
const timeRaw = getFirstFlag(flags, ['time']);
|
|
363
|
+
peekTimeSec = validatePeekTimeSec(timeRaw === undefined ? undefined : Number(timeRaw));
|
|
364
|
+
} catch (error: any) {
|
|
365
|
+
stderr(`${error.message}\n`);
|
|
366
|
+
stdout(CLI_HELP_TEXT);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
writeJson(stdout, await peekProcesses(pids, peekTimeSec));
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
326
374
|
if (command === 'kill') {
|
|
327
375
|
const { positionals, flags } = parseArgs(argv.slice(1));
|
|
328
376
|
if (hasHelpFlag(flags)) {
|
package/src/app/mcp.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from '../cli-utils.js';
|
|
12
12
|
import { getModelParameterDescription, getSupportedModelsDescription } from '../model-catalog.js';
|
|
13
|
+
import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
|
|
13
14
|
import { ProcessService } from '../process-service.js';
|
|
14
15
|
|
|
15
16
|
// Server version - update this when releasing new versions
|
|
@@ -230,6 +231,25 @@ ${getSupportedModelsDescription()}
|
|
|
230
231
|
required: ['pids'],
|
|
231
232
|
},
|
|
232
233
|
},
|
|
234
|
+
{
|
|
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: [].',
|
|
237
|
+
inputSchema: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
pids: {
|
|
241
|
+
type: 'array',
|
|
242
|
+
items: { type: 'number' },
|
|
243
|
+
description: 'Process IDs returned by run. Duplicates are deduplicated server-side, preserving first occurrence order. Unknown PIDs are returned per process as not_found.',
|
|
244
|
+
},
|
|
245
|
+
peek_time_sec: {
|
|
246
|
+
type: 'number',
|
|
247
|
+
description: 'Optional positive integer observation window in seconds. Defaults to 10; maximum is 60.',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
required: ['pids'],
|
|
251
|
+
},
|
|
252
|
+
},
|
|
233
253
|
{
|
|
234
254
|
name: 'kill_process',
|
|
235
255
|
description: 'Terminate a running AI agent process by PID.',
|
|
@@ -270,6 +290,8 @@ ${getSupportedModelsDescription()}
|
|
|
270
290
|
return this.handleGetResult(toolArguments);
|
|
271
291
|
case 'wait':
|
|
272
292
|
return this.handleWait(toolArguments);
|
|
293
|
+
case 'peek':
|
|
294
|
+
return this.handlePeek(toolArguments);
|
|
273
295
|
case 'kill_process':
|
|
274
296
|
return this.handleKillProcess(toolArguments);
|
|
275
297
|
case 'cleanup_processes':
|
|
@@ -359,6 +381,30 @@ ${getSupportedModelsDescription()}
|
|
|
359
381
|
}
|
|
360
382
|
}
|
|
361
383
|
|
|
384
|
+
private async handlePeek(toolArguments: any): Promise<ServerResult> {
|
|
385
|
+
let pids: number[];
|
|
386
|
+
let peekTimeSec: number;
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
pids = validatePeekPids(toolArguments.pids);
|
|
390
|
+
peekTimeSec = validatePeekTimeSec(toolArguments.peek_time_sec);
|
|
391
|
+
} catch (error: any) {
|
|
392
|
+
throw new McpError(ErrorCode.InvalidParams, error.message);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const response = await this.processService.peekProcesses(pids, peekTimeSec);
|
|
397
|
+
return {
|
|
398
|
+
content: [{
|
|
399
|
+
type: 'text',
|
|
400
|
+
text: JSON.stringify(response, null, 2)
|
|
401
|
+
}]
|
|
402
|
+
};
|
|
403
|
+
} catch (error: any) {
|
|
404
|
+
throw new McpError(ErrorCode.InternalError, `Failed to peek processes: ${error.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
362
408
|
private async handleKillProcess(toolArguments: any): Promise<ServerResult> {
|
|
363
409
|
if (!toolArguments.pid || typeof toolArguments.pid !== 'number') {
|
|
364
410
|
throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pid');
|
package/src/cli-builder.ts
CHANGED
|
@@ -219,7 +219,7 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
|
|
|
219
219
|
args.push('--skip-git-repo-check', '--full-auto', '--json', prompt);
|
|
220
220
|
} else if (agent === 'gemini') {
|
|
221
221
|
cliPath = options.cliPaths.gemini;
|
|
222
|
-
args = ['-y', '--output-format', 'json'];
|
|
222
|
+
args = ['-y', '--output-format', 'stream-json'];
|
|
223
223
|
|
|
224
224
|
if (options.session_id && typeof options.session_id === 'string') {
|
|
225
225
|
args.push('-r', options.session_id);
|
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
existsSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
openSync,
|
|
8
|
+
readSync,
|
|
8
9
|
readFileSync,
|
|
9
10
|
readdirSync,
|
|
10
11
|
realpathSync,
|
|
11
12
|
renameSync,
|
|
12
13
|
rmSync,
|
|
14
|
+
statSync,
|
|
13
15
|
unlinkSync,
|
|
14
16
|
writeFileSync,
|
|
15
17
|
} from 'node:fs';
|
|
@@ -17,8 +19,17 @@ import { join, basename, dirname } from 'node:path';
|
|
|
17
19
|
import { homedir } from 'node:os';
|
|
18
20
|
import { buildCliCommand, type BuildCliCommandOptions } from './cli-builder.js';
|
|
19
21
|
import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
|
|
20
|
-
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
|
|
22
|
+
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
|
|
21
23
|
import { buildProcessResult } from './process-result.js';
|
|
24
|
+
import {
|
|
25
|
+
appendPeekMessages,
|
|
26
|
+
buildNotFoundPeekProcess,
|
|
27
|
+
observedDurationSec,
|
|
28
|
+
validatePeekPids,
|
|
29
|
+
validatePeekTimeSec,
|
|
30
|
+
type PeekProcessResult,
|
|
31
|
+
type PeekResponse,
|
|
32
|
+
} from './peek.js';
|
|
22
33
|
import type { AgentType, ProcessListItem } from './process-service.js';
|
|
23
34
|
|
|
24
35
|
interface StoredProcess {
|
|
@@ -244,6 +255,97 @@ export class CliProcessService {
|
|
|
244
255
|
}
|
|
245
256
|
}
|
|
246
257
|
|
|
258
|
+
async peekProcesses(pids: number[], peekTimeSec = 10): Promise<PeekResponse> {
|
|
259
|
+
const targetPids = validatePeekPids(pids);
|
|
260
|
+
const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
|
|
261
|
+
const processes: PeekProcessResult[] = [];
|
|
262
|
+
const observers: Array<{
|
|
263
|
+
process: StoredProcess;
|
|
264
|
+
result: PeekProcessResult;
|
|
265
|
+
stdoutExtractor: PeekMessageExtractor;
|
|
266
|
+
stderrExtractor: PeekMessageExtractor;
|
|
267
|
+
stdoutOffset: number;
|
|
268
|
+
stderrOffset: number;
|
|
269
|
+
}> = [];
|
|
270
|
+
|
|
271
|
+
for (const pid of targetPids) {
|
|
272
|
+
let process: StoredProcess;
|
|
273
|
+
try {
|
|
274
|
+
process = this.refreshStatus(this.readProcess(pid));
|
|
275
|
+
} catch {
|
|
276
|
+
processes.push(buildNotFoundPeekProcess(pid));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const result: PeekProcessResult = {
|
|
281
|
+
pid,
|
|
282
|
+
agent: process.toolType,
|
|
283
|
+
status: process.status,
|
|
284
|
+
messages: [],
|
|
285
|
+
truncated: false,
|
|
286
|
+
error: null,
|
|
287
|
+
};
|
|
288
|
+
processes.push(result);
|
|
289
|
+
observers.push({
|
|
290
|
+
process,
|
|
291
|
+
result,
|
|
292
|
+
stdoutExtractor: new PeekMessageExtractor(process.toolType),
|
|
293
|
+
stderrExtractor: new PeekMessageExtractor(process.toolType),
|
|
294
|
+
stdoutOffset: this.fileSizeSafe(process.stdoutPath),
|
|
295
|
+
stderrOffset: this.fileSizeSafe(process.stderrPath),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const startedAt = new Date();
|
|
300
|
+
const startedAtMs = Date.now();
|
|
301
|
+
const deadlineMs = startedAtMs + targetPeekTimeSec * 1000;
|
|
302
|
+
|
|
303
|
+
while (Date.now() <= deadlineMs) {
|
|
304
|
+
const observedAt = new Date().toISOString();
|
|
305
|
+
let allTerminal = true;
|
|
306
|
+
|
|
307
|
+
for (const observer of observers) {
|
|
308
|
+
const stdoutRead = this.readTextFromOffset(observer.process.stdoutPath, observer.stdoutOffset);
|
|
309
|
+
observer.stdoutOffset = stdoutRead.offset;
|
|
310
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.push(stdoutRead.text, observedAt));
|
|
311
|
+
|
|
312
|
+
const stderrRead = this.readTextFromOffset(observer.process.stderrPath, observer.stderrOffset);
|
|
313
|
+
observer.stderrOffset = stderrRead.offset;
|
|
314
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.push(stderrRead.text, observedAt));
|
|
315
|
+
|
|
316
|
+
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
317
|
+
observer.result.status = observer.process.status;
|
|
318
|
+
if (observer.process.status === 'running') {
|
|
319
|
+
allTerminal = false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (allTerminal) {
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const remainingMs = deadlineMs - Date.now();
|
|
328
|
+
if (remainingMs <= 0) {
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs)));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const flushTs = new Date().toISOString();
|
|
335
|
+
for (const observer of observers) {
|
|
336
|
+
observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
|
|
337
|
+
observer.result.status = observer.process.status;
|
|
338
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
|
|
339
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
peek_started_at: startedAt.toISOString(),
|
|
344
|
+
observed_duration_sec: observedDurationSec(startedAtMs),
|
|
345
|
+
processes,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
247
349
|
async killProcess(pid: number): Promise<{ pid: number; status: string; message: string }> {
|
|
248
350
|
const process = this.readProcess(pid);
|
|
249
351
|
const refreshed = this.refreshStatus(process);
|
|
@@ -445,6 +547,37 @@ export class CliProcessService {
|
|
|
445
547
|
return readFileSync(filePath, 'utf-8');
|
|
446
548
|
}
|
|
447
549
|
|
|
550
|
+
private fileSizeSafe(filePath: string): number {
|
|
551
|
+
if (!existsSync(filePath)) {
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
return statSync(filePath).size;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private readTextFromOffset(filePath: string, offset: number): { text: string; offset: number } {
|
|
558
|
+
if (!existsSync(filePath)) {
|
|
559
|
+
return { text: '', offset };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const size = statSync(filePath).size;
|
|
563
|
+
if (size <= offset) {
|
|
564
|
+
return { text: '', offset: size };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const fd = openSync(filePath, 'r');
|
|
568
|
+
try {
|
|
569
|
+
const length = size - offset;
|
|
570
|
+
const buffer = Buffer.alloc(length);
|
|
571
|
+
const bytesRead = readSync(fd, buffer, 0, length, offset);
|
|
572
|
+
return {
|
|
573
|
+
text: buffer.subarray(0, bytesRead).toString('utf-8'),
|
|
574
|
+
offset: size,
|
|
575
|
+
};
|
|
576
|
+
} finally {
|
|
577
|
+
closeSync(fd);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
448
581
|
private resolveCwdsDir(): string {
|
|
449
582
|
return join(this.stateDir, 'cwds');
|
|
450
583
|
}
|