ai-cli-mcp 2.15.0 → 2.17.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 +14 -0
- package/README.ja.md +59 -1
- package/README.md +59 -1
- package/dist/__tests__/app-cli.test.js +56 -1
- package/dist/__tests__/cli-builder.test.js +3 -2
- 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 +2 -2
- 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 +3 -2
- 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 +2 -2
- 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
|
@@ -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(
|
|
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('
|
|
417
|
-
expect(result.tools[5].name).toBe('
|
|
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');
|
package/dist/cli-builder.js
CHANGED
|
@@ -155,11 +155,11 @@ export function buildCliCommand(options) {
|
|
|
155
155
|
if (resolvedModel) {
|
|
156
156
|
args.push('--model', resolvedModel);
|
|
157
157
|
}
|
|
158
|
-
args.push('--skip-git-repo-check', '--
|
|
158
|
+
args.push('--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', '--json', prompt);
|
|
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
|
}
|