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
|
@@ -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
|
}
|
package/src/parsers.ts
CHANGED
|
@@ -1,5 +1,131 @@
|
|
|
1
1
|
import { debugLog } from './cli-utils.js';
|
|
2
2
|
|
|
3
|
+
export interface PeekMessage {
|
|
4
|
+
ts: string;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type PeekAgent = 'claude' | 'codex' | string | null;
|
|
9
|
+
|
|
10
|
+
function isGeminiAssistantMessageEvent(parsed: any): boolean {
|
|
11
|
+
return parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const GEMINI_STREAM_EVENT_TYPES = new Set([
|
|
15
|
+
'init',
|
|
16
|
+
'message',
|
|
17
|
+
'tool_use',
|
|
18
|
+
'tool_result',
|
|
19
|
+
'result',
|
|
20
|
+
'error',
|
|
21
|
+
'stats',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function isGeminiStreamJsonEvent(parsed: any): boolean {
|
|
25
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && GEMINI_STREAM_EVENT_TYPES.has(parsed.type);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractPeekMessagesFromParsedEvent(agent: PeekAgent, parsed: any, observedAt: string): PeekMessage[] {
|
|
29
|
+
if (agent === 'codex') {
|
|
30
|
+
if (parsed.item?.type === 'agent_message' && typeof parsed.item.text === 'string' && parsed.item.text.trim()) {
|
|
31
|
+
return [{ ts: observedAt, text: parsed.item.text }];
|
|
32
|
+
}
|
|
33
|
+
if (parsed.msg?.type === 'agent_message' && typeof parsed.msg.message === 'string' && parsed.msg.message.trim()) {
|
|
34
|
+
return [{ ts: observedAt, text: parsed.msg.message }];
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (agent === 'claude' && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
40
|
+
return parsed.message.content
|
|
41
|
+
.filter((content: any) => content?.type === 'text' && typeof content.text === 'string' && content.text.trim())
|
|
42
|
+
.map((content: any) => ({ ts: observedAt, text: content.text }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (agent === 'opencode' && parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string' && parsed.part.text.trim()) {
|
|
46
|
+
return [{ ts: observedAt, text: parsed.part.text }];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class PeekMessageExtractor {
|
|
53
|
+
private pending = '';
|
|
54
|
+
private geminiAssistantBuffer = '';
|
|
55
|
+
|
|
56
|
+
constructor(private readonly agent: PeekAgent) {}
|
|
57
|
+
|
|
58
|
+
push(chunk: string, observedAt = new Date().toISOString()): PeekMessage[] {
|
|
59
|
+
if (!chunk) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines = `${this.pending}${chunk}`.split(/\r?\n/);
|
|
64
|
+
this.pending = lines.pop() || '';
|
|
65
|
+
return this.extractLines(lines, observedAt);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
flush(observedAt = new Date().toISOString()): PeekMessage[] {
|
|
69
|
+
const messages: PeekMessage[] = [];
|
|
70
|
+
|
|
71
|
+
if (this.pending) {
|
|
72
|
+
const line = this.pending;
|
|
73
|
+
this.pending = '';
|
|
74
|
+
messages.push(...this.extractLines([line], observedAt));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
messages.push(...this.flushGeminiAssistantBuffer(observedAt));
|
|
78
|
+
return messages;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private extractLines(lines: string[], observedAt: string): PeekMessage[] {
|
|
82
|
+
const messages: PeekMessage[] = [];
|
|
83
|
+
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (!line.trim()) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
messages.push(...this.extractParsedEvent(JSON.parse(line), observedAt));
|
|
91
|
+
} catch {
|
|
92
|
+
debugLog(`[Debug] Skipping invalid peek JSON line: ${line}`);
|
|
93
|
+
messages.push(...this.flushGeminiAssistantBuffer(observedAt));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return messages;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private extractParsedEvent(parsed: any, observedAt: string): PeekMessage[] {
|
|
101
|
+
if (this.agent !== 'gemini') {
|
|
102
|
+
return extractPeekMessagesFromParsedEvent(this.agent, parsed, observedAt);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (isGeminiAssistantMessageEvent(parsed)) {
|
|
106
|
+
this.geminiAssistantBuffer += parsed.content;
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return this.flushGeminiAssistantBuffer(observedAt);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private flushGeminiAssistantBuffer(observedAt: string): PeekMessage[] {
|
|
114
|
+
if (this.agent !== 'gemini' || !this.geminiAssistantBuffer) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const text = this.geminiAssistantBuffer;
|
|
119
|
+
this.geminiAssistantBuffer = '';
|
|
120
|
+
|
|
121
|
+
if (!text.trim()) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [{ ts: observedAt, text }];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
3
129
|
export function parseCodexOutput(stdout: string): any {
|
|
4
130
|
if (!stdout) return null;
|
|
5
131
|
|
|
@@ -142,11 +268,105 @@ export function parseGeminiOutput(stdout: string): any {
|
|
|
142
268
|
if (!stdout) return null;
|
|
143
269
|
|
|
144
270
|
try {
|
|
145
|
-
|
|
271
|
+
const parsed = JSON.parse(stdout.trim());
|
|
272
|
+
if (!isGeminiStreamJsonEvent(parsed)) {
|
|
273
|
+
return parsed;
|
|
274
|
+
}
|
|
146
275
|
} catch (e) {
|
|
147
276
|
debugLog(`[Debug] Failed to parse Gemini JSON output: ${e}`);
|
|
148
|
-
return null;
|
|
149
277
|
}
|
|
278
|
+
|
|
279
|
+
let sessionId: string | null = null;
|
|
280
|
+
let assistantBuffer = '';
|
|
281
|
+
let lastMessage: string | null = null;
|
|
282
|
+
let stats: any = null;
|
|
283
|
+
const toolsById = new Map<string, any>();
|
|
284
|
+
const toolsWithoutId: any[] = [];
|
|
285
|
+
const flushAssistantMessage = () => {
|
|
286
|
+
if (assistantBuffer.trim()) {
|
|
287
|
+
lastMessage = assistantBuffer;
|
|
288
|
+
}
|
|
289
|
+
assistantBuffer = '';
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
for (const line of stdout.split('\n')) {
|
|
293
|
+
if (!line.trim()) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let parsed: any;
|
|
298
|
+
try {
|
|
299
|
+
parsed = JSON.parse(line);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
debugLog(`[Debug] Skipping invalid Gemini stream-json line: ${line}`);
|
|
302
|
+
flushAssistantMessage();
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (parsed.type === 'init' && typeof parsed.session_id === 'string' && parsed.session_id) {
|
|
307
|
+
sessionId = parsed.session_id;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (isGeminiAssistantMessageEvent(parsed)) {
|
|
312
|
+
assistantBuffer += parsed.content;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
flushAssistantMessage();
|
|
317
|
+
|
|
318
|
+
if (parsed.type === 'result') {
|
|
319
|
+
if (parsed.stats) {
|
|
320
|
+
stats = parsed.stats;
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (parsed.type === 'tool_use') {
|
|
326
|
+
const tool = {
|
|
327
|
+
tool: parsed.tool_name || parsed.name || 'tool_use',
|
|
328
|
+
input: parsed.parameters ?? parsed.input ?? null,
|
|
329
|
+
output: null,
|
|
330
|
+
status: null,
|
|
331
|
+
};
|
|
332
|
+
if (typeof parsed.tool_id === 'string' && parsed.tool_id) {
|
|
333
|
+
toolsById.set(parsed.tool_id, tool);
|
|
334
|
+
} else {
|
|
335
|
+
toolsWithoutId.push(tool);
|
|
336
|
+
}
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (parsed.type === 'tool_result') {
|
|
341
|
+
const toolId = typeof parsed.tool_id === 'string' ? parsed.tool_id : '';
|
|
342
|
+
const tool = toolId ? toolsById.get(toolId) : null;
|
|
343
|
+
if (tool) {
|
|
344
|
+
tool.output = parsed.output ?? parsed.result ?? null;
|
|
345
|
+
tool.status = parsed.status ?? null;
|
|
346
|
+
} else {
|
|
347
|
+
toolsWithoutId.push({
|
|
348
|
+
tool: 'tool_result',
|
|
349
|
+
input: null,
|
|
350
|
+
output: parsed.output ?? parsed.result ?? null,
|
|
351
|
+
status: parsed.status ?? null,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
flushAssistantMessage();
|
|
358
|
+
const tools = [...toolsById.values(), ...toolsWithoutId];
|
|
359
|
+
|
|
360
|
+
if (lastMessage || sessionId || stats || tools.length > 0) {
|
|
361
|
+
return {
|
|
362
|
+
message: lastMessage,
|
|
363
|
+
session_id: sessionId,
|
|
364
|
+
stats: stats || undefined,
|
|
365
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return null;
|
|
150
370
|
}
|
|
151
371
|
|
|
152
372
|
export function parseForgeOutput(stdout: string): any {
|
package/src/peek.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { PeekMessage } from './parsers.js';
|
|
2
|
+
import type { AgentType, ProcessStatus } from './process-service.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PEEK_TIME_SEC = 10;
|
|
5
|
+
export const MAX_PEEK_TIME_SEC = 60;
|
|
6
|
+
export const MAX_PEEK_PIDS = 32;
|
|
7
|
+
export const PEEK_MESSAGE_CAP = 50;
|
|
8
|
+
|
|
9
|
+
export type PeekStatus = ProcessStatus | 'not_found';
|
|
10
|
+
export type PeekAgent = AgentType | string | null;
|
|
11
|
+
|
|
12
|
+
export interface PeekProcessResult {
|
|
13
|
+
pid: number;
|
|
14
|
+
agent: PeekAgent;
|
|
15
|
+
status: PeekStatus;
|
|
16
|
+
messages: PeekMessage[];
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
error: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PeekResponse {
|
|
22
|
+
peek_started_at: string;
|
|
23
|
+
observed_duration_sec: number;
|
|
24
|
+
processes: PeekProcessResult[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validatePeekPids(value: unknown): number[] {
|
|
28
|
+
if (!Array.isArray(value)) {
|
|
29
|
+
throw new Error('Missing or invalid required parameter: pids (must be an array of positive safe integers)');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const deduped: number[] = [];
|
|
33
|
+
const seen = new Set<number>();
|
|
34
|
+
|
|
35
|
+
for (const pid of value) {
|
|
36
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
37
|
+
throw new Error('All pids must be positive safe integers');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!seen.has(pid)) {
|
|
41
|
+
seen.add(pid);
|
|
42
|
+
deduped.push(pid);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (deduped.length === 0 || deduped.length > MAX_PEEK_PIDS) {
|
|
47
|
+
throw new Error(`pids must contain 1..${MAX_PEEK_PIDS} entries after dedupe`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return deduped;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validatePeekTimeSec(value: unknown): number {
|
|
54
|
+
if (value === undefined || value === null) {
|
|
55
|
+
return DEFAULT_PEEK_TIME_SEC;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > MAX_PEEK_TIME_SEC) {
|
|
59
|
+
throw new Error(`peek_time_sec must be a positive integer no greater than ${MAX_PEEK_TIME_SEC}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildNotFoundPeekProcess(pid: number): PeekProcessResult {
|
|
66
|
+
return {
|
|
67
|
+
pid,
|
|
68
|
+
agent: null,
|
|
69
|
+
status: 'not_found',
|
|
70
|
+
messages: [],
|
|
71
|
+
truncated: false,
|
|
72
|
+
error: 'process not found',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function appendPeekMessages(target: PeekProcessResult, messages: PeekMessage[]): void {
|
|
77
|
+
for (const message of messages) {
|
|
78
|
+
if (target.messages.length < PEEK_MESSAGE_CAP) {
|
|
79
|
+
target.messages.push(message);
|
|
80
|
+
} else {
|
|
81
|
+
target.truncated = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function observedDurationSec(startedAtMs: number, endedAtMs = Date.now()): number {
|
|
87
|
+
return Number(((endedAtMs - startedAtMs) / 1000).toFixed(2));
|
|
88
|
+
}
|
package/src/process-service.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
2
|
import { buildCliCommand, type BuildCliCommandOptions } 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 {
|
|
5
|
+
appendPeekMessages,
|
|
6
|
+
buildNotFoundPeekProcess,
|
|
7
|
+
observedDurationSec,
|
|
8
|
+
validatePeekPids,
|
|
9
|
+
validatePeekTimeSec,
|
|
10
|
+
type PeekProcessResult,
|
|
11
|
+
type PeekResponse,
|
|
12
|
+
} from './peek.js';
|
|
4
13
|
import { buildProcessResult } from './process-result.js';
|
|
5
14
|
|
|
6
15
|
export type AgentType = 'claude' | 'codex' | 'gemini' | 'forge' | 'opencode';
|
|
@@ -217,6 +226,103 @@ export class ProcessService {
|
|
|
217
226
|
}
|
|
218
227
|
}
|
|
219
228
|
|
|
229
|
+
async peekProcesses(pids: number[], peekTimeSec = 10): Promise<PeekResponse> {
|
|
230
|
+
const targetPids = validatePeekPids(pids);
|
|
231
|
+
const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
|
|
232
|
+
const processes: PeekProcessResult[] = [];
|
|
233
|
+
const observers: Array<{
|
|
234
|
+
entry: TrackedProcess;
|
|
235
|
+
result: PeekProcessResult;
|
|
236
|
+
stdoutExtractor: PeekMessageExtractor;
|
|
237
|
+
stderrExtractor: PeekMessageExtractor;
|
|
238
|
+
onStdout: (data: Buffer | string) => void;
|
|
239
|
+
onStderr: (data: Buffer | string) => void;
|
|
240
|
+
}> = [];
|
|
241
|
+
|
|
242
|
+
for (const pid of targetPids) {
|
|
243
|
+
const entry = this.processManager.get(pid);
|
|
244
|
+
if (!entry) {
|
|
245
|
+
processes.push(buildNotFoundPeekProcess(pid));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const result: PeekProcessResult = {
|
|
250
|
+
pid,
|
|
251
|
+
agent: entry.toolType,
|
|
252
|
+
status: entry.status,
|
|
253
|
+
messages: [],
|
|
254
|
+
truncated: false,
|
|
255
|
+
error: null,
|
|
256
|
+
};
|
|
257
|
+
processes.push(result);
|
|
258
|
+
|
|
259
|
+
const stdoutExtractor = new PeekMessageExtractor(entry.toolType);
|
|
260
|
+
const stderrExtractor = new PeekMessageExtractor(entry.toolType);
|
|
261
|
+
const onStdout = (data: Buffer | string) => {
|
|
262
|
+
appendPeekMessages(result, stdoutExtractor.push(data.toString(), new Date().toISOString()));
|
|
263
|
+
};
|
|
264
|
+
const onStderr = (data: Buffer | string) => {
|
|
265
|
+
appendPeekMessages(result, stderrExtractor.push(data.toString(), new Date().toISOString()));
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (entry.status === 'running') {
|
|
269
|
+
entry.process.stdout?.on('data', onStdout);
|
|
270
|
+
entry.process.stderr?.on('data', onStderr);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
observers.push({ entry, result, stdoutExtractor, stderrExtractor, onStdout, onStderr });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const startedAt = new Date();
|
|
277
|
+
const startedAtMs = Date.now();
|
|
278
|
+
const runningObservers = observers.filter((observer) => observer.entry.status === 'running');
|
|
279
|
+
const terminalPromise = Promise.all(runningObservers.map((observer) => this.waitForProcessTerminal(observer.entry)));
|
|
280
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
281
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
282
|
+
timeoutHandle = setTimeout(resolve, targetPeekTimeSec * 1000);
|
|
283
|
+
timeoutHandle.unref?.();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
await Promise.race([terminalPromise, timeoutPromise]);
|
|
288
|
+
} finally {
|
|
289
|
+
if (timeoutHandle) {
|
|
290
|
+
clearTimeout(timeoutHandle);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const flushTs = new Date().toISOString();
|
|
294
|
+
for (const observer of observers) {
|
|
295
|
+
observer.entry.process.stdout?.off('data', observer.onStdout);
|
|
296
|
+
observer.entry.process.stderr?.off('data', observer.onStderr);
|
|
297
|
+
appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
|
|
298
|
+
appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
|
|
299
|
+
observer.result.status = observer.entry.status;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
peek_started_at: startedAt.toISOString(),
|
|
305
|
+
observed_duration_sec: observedDurationSec(startedAtMs),
|
|
306
|
+
processes,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private waitForProcessTerminal(processEntry: TrackedProcess): Promise<void> {
|
|
311
|
+
if (processEntry.status !== 'running') {
|
|
312
|
+
return Promise.resolve();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return new Promise<void>((resolve) => {
|
|
316
|
+
const done = () => {
|
|
317
|
+
processEntry.process.off('close', done);
|
|
318
|
+
processEntry.process.off('error', done);
|
|
319
|
+
resolve();
|
|
320
|
+
};
|
|
321
|
+
processEntry.process.once('close', done);
|
|
322
|
+
processEntry.process.once('error', done);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
220
326
|
killProcess(pid: number): { pid: number; status: string; message: string } {
|
|
221
327
|
const processEntry = this.processManager.get(pid);
|
|
222
328
|
if (!processEntry) {
|