ai-cli-mcp 2.13.0 → 2.14.1
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 +10 -0
- package/README.md +10 -0
- package/dist/__tests__/app-cli.test.js +26 -2
- package/dist/__tests__/cli-process-service.test.js +218 -5
- package/dist/__tests__/mcp-contract.test.js +138 -8
- package/dist/__tests__/process-management.test.js +2 -1
- package/dist/app/cli.js +6 -4
- package/dist/app/mcp.js +8 -4
- package/dist/cli-process-service.js +26 -26
- package/dist/process-result.js +51 -0
- package/dist/process-service.js +7 -21
- package/package.json +1 -1
- package/src/__tests__/app-cli.test.ts +35 -1
- package/src/__tests__/cli-process-service.test.ts +249 -5
- package/src/__tests__/mcp-contract.test.ts +152 -8
- package/src/__tests__/process-management.test.ts +2 -1
- package/src/app/cli.ts +7 -5
- package/src/app/mcp.ts +9 -4
- package/src/cli-process-service.ts +29 -26
- package/src/process-result.ts +79 -0
- package/src/process-service.ts +7 -22
package/dist/app/mcp.js
CHANGED
|
@@ -165,7 +165,7 @@ ${getSupportedModelsDescription()}
|
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
name: 'get_result',
|
|
168
|
-
description: 'Get the current output and status of an AI agent process by PID.
|
|
168
|
+
description: 'Get the current output and status of an AI agent process by PID. Defaults to a compact result shape; set verbose to true for full metadata and detailed parsed output.',
|
|
169
169
|
inputSchema: {
|
|
170
170
|
type: 'object',
|
|
171
171
|
properties: {
|
|
@@ -175,7 +175,7 @@ ${getSupportedModelsDescription()}
|
|
|
175
175
|
},
|
|
176
176
|
verbose: {
|
|
177
177
|
type: 'boolean',
|
|
178
|
-
description: 'Optional: If true, returns
|
|
178
|
+
description: 'Optional: If true, returns the full result shape including metadata fields and detailed parsed output such as tool usage history. Defaults to false.',
|
|
179
179
|
}
|
|
180
180
|
},
|
|
181
181
|
required: ['pid'],
|
|
@@ -183,7 +183,7 @@ ${getSupportedModelsDescription()}
|
|
|
183
183
|
},
|
|
184
184
|
{
|
|
185
185
|
name: 'wait',
|
|
186
|
-
description: 'Wait for multiple AI agent processes to complete and return their results.
|
|
186
|
+
description: 'Wait for multiple AI agent processes to complete and return their results. Defaults to compact result items; set verbose to true for full metadata and detailed parsed output.',
|
|
187
187
|
inputSchema: {
|
|
188
188
|
type: 'object',
|
|
189
189
|
properties: {
|
|
@@ -196,6 +196,10 @@ ${getSupportedModelsDescription()}
|
|
|
196
196
|
type: 'number',
|
|
197
197
|
description: 'Optional: Maximum time to wait in seconds. Defaults to 180 (3 minutes).',
|
|
198
198
|
},
|
|
199
|
+
verbose: {
|
|
200
|
+
type: 'boolean',
|
|
201
|
+
description: 'Optional: If true, each result item uses the full result shape including metadata fields and detailed parsed output. Defaults to false.',
|
|
202
|
+
},
|
|
199
203
|
},
|
|
200
204
|
required: ['pids'],
|
|
201
205
|
},
|
|
@@ -305,7 +309,7 @@ ${getSupportedModelsDescription()}
|
|
|
305
309
|
throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pids (must be a non-empty array of numbers)');
|
|
306
310
|
}
|
|
307
311
|
try {
|
|
308
|
-
const results = await this.processService.waitForProcesses(toolArguments.pids, typeof toolArguments.timeout === 'number' ? toolArguments.timeout : 180);
|
|
312
|
+
const results = await this.processService.waitForProcesses(toolArguments.pids, typeof toolArguments.timeout === 'number' ? toolArguments.timeout : 180, !!toolArguments.verbose);
|
|
309
313
|
return {
|
|
310
314
|
content: [{
|
|
311
315
|
type: 'text',
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
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 } from './cli-utils.js';
|
|
7
7
|
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
|
|
8
|
+
import { buildProcessResult } from './process-result.js';
|
|
8
9
|
function resolveDefaultStateDir() {
|
|
9
10
|
return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
|
|
10
11
|
}
|
|
@@ -76,6 +77,7 @@ export class CliProcessService {
|
|
|
76
77
|
pid,
|
|
77
78
|
prompt: cmd.prompt,
|
|
78
79
|
workFolder: cmd.cwd,
|
|
80
|
+
cwdKey: this.resolveCwdKey(cmd.cwd),
|
|
79
81
|
model: options.model,
|
|
80
82
|
toolType: cmd.agent,
|
|
81
83
|
startTime: new Date().toISOString(),
|
|
@@ -132,7 +134,7 @@ export class CliProcessService {
|
|
|
132
134
|
agentOutput = parseForgeOutput(stdout);
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
|
-
|
|
137
|
+
return buildProcessResult({
|
|
136
138
|
pid,
|
|
137
139
|
agent: refreshed.toolType,
|
|
138
140
|
status: refreshed.status,
|
|
@@ -141,26 +143,11 @@ export class CliProcessService {
|
|
|
141
143
|
workFolder: refreshed.workFolder,
|
|
142
144
|
prompt: refreshed.prompt,
|
|
143
145
|
model: refreshed.model,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const { tools, ...rest } = agentOutput;
|
|
148
|
-
response.agentOutput = rest;
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
response.agentOutput = agentOutput;
|
|
152
|
-
}
|
|
153
|
-
if (agentOutput.session_id) {
|
|
154
|
-
response.session_id = agentOutput.session_id;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
response.stdout = stdout;
|
|
159
|
-
response.stderr = stderr;
|
|
160
|
-
}
|
|
161
|
-
return response;
|
|
146
|
+
stdout,
|
|
147
|
+
stderr,
|
|
148
|
+
}, agentOutput, verbose);
|
|
162
149
|
}
|
|
163
|
-
async waitForProcesses(pids, timeoutSeconds = 180) {
|
|
150
|
+
async waitForProcesses(pids, timeoutSeconds = 180, verbose = false) {
|
|
164
151
|
const start = Date.now();
|
|
165
152
|
for (const pid of pids) {
|
|
166
153
|
this.readProcess(pid);
|
|
@@ -168,7 +155,7 @@ export class CliProcessService {
|
|
|
168
155
|
while (true) {
|
|
169
156
|
const statuses = pids.map((pid) => this.refreshStatus(this.readProcess(pid)).status);
|
|
170
157
|
if (statuses.every((status) => status !== 'running')) {
|
|
171
|
-
return Promise.all(pids.map((pid) => this.getProcessResult(pid,
|
|
158
|
+
return Promise.all(pids.map((pid) => this.getProcessResult(pid, verbose)));
|
|
172
159
|
}
|
|
173
160
|
if (Date.now() - start >= timeoutSeconds * 1000) {
|
|
174
161
|
throw new Error(`Timed out after ${timeoutSeconds} seconds waiting for processes`);
|
|
@@ -210,7 +197,7 @@ export class CliProcessService {
|
|
|
210
197
|
if (refreshed.status === 'running') {
|
|
211
198
|
continue;
|
|
212
199
|
}
|
|
213
|
-
const processDir = this.
|
|
200
|
+
const processDir = this.resolveStoredProcessDir(refreshed);
|
|
214
201
|
if (existsSync(processDir)) {
|
|
215
202
|
rmSync(processDir, { recursive: true, force: true });
|
|
216
203
|
removed++;
|
|
@@ -247,10 +234,14 @@ export class CliProcessService {
|
|
|
247
234
|
return process;
|
|
248
235
|
}
|
|
249
236
|
parseProcessFile(metaPath) {
|
|
250
|
-
|
|
237
|
+
const process = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
238
|
+
if (!process.cwdKey) {
|
|
239
|
+
process.cwdKey = basename(dirname(dirname(metaPath)));
|
|
240
|
+
}
|
|
241
|
+
return process;
|
|
251
242
|
}
|
|
252
243
|
writeProcess(process) {
|
|
253
|
-
const processDir = this.
|
|
244
|
+
const processDir = this.resolveStoredProcessDir(process);
|
|
254
245
|
mkdirSync(processDir, { recursive: true });
|
|
255
246
|
writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
|
|
256
247
|
}
|
|
@@ -271,7 +262,16 @@ export class CliProcessService {
|
|
|
271
262
|
return join(this.stateDir, 'cwds');
|
|
272
263
|
}
|
|
273
264
|
resolveProcessDir(cwd, pid) {
|
|
274
|
-
return join(this.resolveCwdsDir(),
|
|
265
|
+
return join(this.resolveCwdsDir(), this.resolveCwdKey(cwd), String(pid));
|
|
266
|
+
}
|
|
267
|
+
resolveStoredProcessDir(process) {
|
|
268
|
+
if (!process.cwdKey) {
|
|
269
|
+
process.cwdKey = this.resolveCwdKey(process.workFolder);
|
|
270
|
+
}
|
|
271
|
+
return join(this.resolveCwdsDir(), process.cwdKey, String(process.pid));
|
|
272
|
+
}
|
|
273
|
+
resolveCwdKey(cwd) {
|
|
274
|
+
return normalizeCwdForStorage(realpathSync(cwd));
|
|
275
275
|
}
|
|
276
276
|
resolveMetaPath(processDir) {
|
|
277
277
|
return join(processDir, 'meta.json');
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function compactAgentOutput(agentOutput) {
|
|
2
|
+
if (!agentOutput || typeof agentOutput !== 'object') {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
const { tools: _tools, ...rest } = agentOutput;
|
|
6
|
+
const compact = Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined && value !== null));
|
|
7
|
+
return Object.keys(compact).length > 0 ? compact : null;
|
|
8
|
+
}
|
|
9
|
+
function hasMeaningfulParsedOutput(agentOutput) {
|
|
10
|
+
if (!agentOutput || typeof agentOutput !== 'object') {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return Object.entries(agentOutput).some(([key, value]) => {
|
|
14
|
+
if (value === undefined || value === null) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (key === 'session_id') {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (key === 'tools') {
|
|
21
|
+
return Array.isArray(value) ? value.length > 0 : true;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function buildProcessResult(context, agentOutput, verbose = false) {
|
|
27
|
+
const response = {
|
|
28
|
+
pid: context.pid,
|
|
29
|
+
agent: context.agent,
|
|
30
|
+
status: context.status,
|
|
31
|
+
exitCode: context.exitCode ?? null,
|
|
32
|
+
model: context.model ?? null,
|
|
33
|
+
};
|
|
34
|
+
if (verbose) {
|
|
35
|
+
response.startTime = context.startTime;
|
|
36
|
+
response.workFolder = context.workFolder;
|
|
37
|
+
response.prompt = context.prompt;
|
|
38
|
+
}
|
|
39
|
+
if (agentOutput?.session_id) {
|
|
40
|
+
response.session_id = agentOutput.session_id;
|
|
41
|
+
}
|
|
42
|
+
const shapedAgentOutput = verbose ? agentOutput : compactAgentOutput(agentOutput);
|
|
43
|
+
if (hasMeaningfulParsedOutput(shapedAgentOutput)) {
|
|
44
|
+
response.agentOutput = shapedAgentOutput;
|
|
45
|
+
}
|
|
46
|
+
if (!response.agentOutput) {
|
|
47
|
+
response.stdout = context.stdout;
|
|
48
|
+
response.stderr = context.stderr;
|
|
49
|
+
}
|
|
50
|
+
return response;
|
|
51
|
+
}
|
package/dist/process-service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { buildCliCommand } from './cli-builder.js';
|
|
3
3
|
import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
|
|
4
|
+
import { buildProcessResult } from './process-result.js';
|
|
4
5
|
export class ProcessService {
|
|
5
6
|
processManager = new Map();
|
|
6
7
|
cliPaths;
|
|
@@ -100,7 +101,7 @@ export class ProcessService {
|
|
|
100
101
|
agentOutput = parseForgeOutput(process.stdout);
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
+
return buildProcessResult({
|
|
104
105
|
pid,
|
|
105
106
|
agent: process.toolType,
|
|
106
107
|
status: process.status,
|
|
@@ -109,26 +110,11 @@ export class ProcessService {
|
|
|
109
110
|
workFolder: process.workFolder,
|
|
110
111
|
prompt: process.prompt,
|
|
111
112
|
model: process.model,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const { tools, ...rest } = agentOutput;
|
|
116
|
-
response.agentOutput = rest;
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
response.agentOutput = agentOutput;
|
|
120
|
-
}
|
|
121
|
-
if (agentOutput.session_id) {
|
|
122
|
-
response.session_id = agentOutput.session_id;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
response.stdout = process.stdout;
|
|
127
|
-
response.stderr = process.stderr;
|
|
128
|
-
}
|
|
129
|
-
return response;
|
|
113
|
+
stdout: process.stdout,
|
|
114
|
+
stderr: process.stderr,
|
|
115
|
+
}, agentOutput, verbose);
|
|
130
116
|
}
|
|
131
|
-
async waitForProcesses(pids, timeoutSeconds = 180) {
|
|
117
|
+
async waitForProcesses(pids, timeoutSeconds = 180, verbose = false) {
|
|
132
118
|
for (const pid of pids) {
|
|
133
119
|
if (!this.processManager.has(pid)) {
|
|
134
120
|
throw new Error(`Process with PID ${pid} not found`);
|
|
@@ -155,7 +141,7 @@ export class ProcessService {
|
|
|
155
141
|
});
|
|
156
142
|
try {
|
|
157
143
|
await Promise.race([Promise.all(waitPromises), timeoutPromise]);
|
|
158
|
-
return pids.map((pid) => this.getProcessResult(pid,
|
|
144
|
+
return pids.map((pid) => this.getProcessResult(pid, verbose));
|
|
159
145
|
}
|
|
160
146
|
finally {
|
|
161
147
|
if (timeoutHandle) {
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
CLI_HELP_TEXT,
|
|
4
4
|
DOCTOR_HELP_TEXT,
|
|
5
5
|
MODELS_HELP_TEXT,
|
|
6
|
+
RESULT_HELP_TEXT,
|
|
6
7
|
RUN_HELP_TEXT,
|
|
7
8
|
WAIT_HELP_TEXT,
|
|
8
9
|
runCli,
|
|
@@ -142,10 +143,28 @@ describe('ai-cli app', () => {
|
|
|
142
143
|
);
|
|
143
144
|
|
|
144
145
|
expect(exitCode).toBe(0);
|
|
145
|
-
expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5);
|
|
146
|
+
expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5, false);
|
|
146
147
|
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"status": "completed"'));
|
|
147
148
|
});
|
|
148
149
|
|
|
150
|
+
it('passes verbose through to wait', async () => {
|
|
151
|
+
const stdout = vi.fn();
|
|
152
|
+
const stderr = vi.fn();
|
|
153
|
+
const waitForProcesses = vi.fn().mockResolvedValue([{ pid: 123, status: 'completed' }]);
|
|
154
|
+
|
|
155
|
+
const exitCode = await runCli(
|
|
156
|
+
['wait', '123', '--verbose'],
|
|
157
|
+
{
|
|
158
|
+
stdout,
|
|
159
|
+
stderr,
|
|
160
|
+
waitForProcesses,
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(exitCode).toBe(0);
|
|
165
|
+
expect(waitForProcesses).toHaveBeenCalledWith([123], undefined, true);
|
|
166
|
+
});
|
|
167
|
+
|
|
149
168
|
it('rejects invalid wait timeout values', async () => {
|
|
150
169
|
const stdout = vi.fn();
|
|
151
170
|
const stderr = vi.fn();
|
|
@@ -291,6 +310,19 @@ describe('ai-cli app', () => {
|
|
|
291
310
|
expect(stderr).not.toHaveBeenCalled();
|
|
292
311
|
});
|
|
293
312
|
|
|
313
|
+
it('prints detailed help for result --help', async () => {
|
|
314
|
+
const stdout = vi.fn();
|
|
315
|
+
const stderr = vi.fn();
|
|
316
|
+
|
|
317
|
+
const exitCode = await runCli(['result', '--help'], { stdout, stderr });
|
|
318
|
+
|
|
319
|
+
expect(exitCode).toBe(0);
|
|
320
|
+
expect(stdout).toHaveBeenCalledWith(RESULT_HELP_TEXT);
|
|
321
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('compact result shape'));
|
|
322
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
|
|
323
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
294
326
|
it('prints detailed help for wait --help', async () => {
|
|
295
327
|
const stdout = vi.fn();
|
|
296
328
|
const stderr = vi.fn();
|
|
@@ -299,6 +331,8 @@ describe('ai-cli app', () => {
|
|
|
299
331
|
|
|
300
332
|
expect(exitCode).toBe(0);
|
|
301
333
|
expect(stdout).toHaveBeenCalledWith(WAIT_HELP_TEXT);
|
|
334
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('compact shape'));
|
|
335
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
|
|
302
336
|
expect(stderr).not.toHaveBeenCalled();
|
|
303
337
|
});
|
|
304
338
|
|
|
@@ -84,8 +84,18 @@ describe('CliProcessService', () => {
|
|
|
84
84
|
|
|
85
85
|
const waitResult = await service.waitForProcesses([runResult.pid], 5);
|
|
86
86
|
expect(waitResult).toHaveLength(1);
|
|
87
|
-
expect(waitResult[0]
|
|
88
|
-
|
|
87
|
+
expect(waitResult[0]).toMatchObject({
|
|
88
|
+
pid: runResult.pid,
|
|
89
|
+
agent: 'claude',
|
|
90
|
+
status: 'completed',
|
|
91
|
+
exitCode: null,
|
|
92
|
+
model: 'sonnet',
|
|
93
|
+
stdout: expect.any(String),
|
|
94
|
+
stderr: expect.any(String),
|
|
95
|
+
});
|
|
96
|
+
expect(waitResult[0]).not.toHaveProperty('startTime');
|
|
97
|
+
expect(waitResult[0]).not.toHaveProperty('workFolder');
|
|
98
|
+
expect(waitResult[0]).not.toHaveProperty('prompt');
|
|
89
99
|
|
|
90
100
|
const listed = await service.listProcesses();
|
|
91
101
|
expect(listed).toContainEqual({
|
|
@@ -95,12 +105,141 @@ describe('CliProcessService', () => {
|
|
|
95
105
|
});
|
|
96
106
|
|
|
97
107
|
const result = await service.getProcessResult(runResult.pid, false);
|
|
98
|
-
expect(result
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
expect(result).toMatchObject({
|
|
109
|
+
pid: runResult.pid,
|
|
110
|
+
agent: 'claude',
|
|
111
|
+
status: 'completed',
|
|
112
|
+
exitCode: null,
|
|
113
|
+
model: 'sonnet',
|
|
114
|
+
stdout: expect.stringContaining('Command executed successfully'),
|
|
115
|
+
stderr: expect.any(String),
|
|
116
|
+
});
|
|
117
|
+
expect(result).not.toHaveProperty('startTime');
|
|
118
|
+
expect(result).not.toHaveProperty('workFolder');
|
|
119
|
+
expect(result).not.toHaveProperty('prompt');
|
|
101
120
|
expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
|
|
102
121
|
});
|
|
103
122
|
|
|
123
|
+
it('returns compact results by default and full results when verbose is true', async () => {
|
|
124
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
125
|
+
tempDirs.push(root);
|
|
126
|
+
const scriptPath = join(root, 'mock-claude-json');
|
|
127
|
+
writeFileSync(
|
|
128
|
+
scriptPath,
|
|
129
|
+
`#!/bin/bash
|
|
130
|
+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/demo.txt"}}]}}'
|
|
131
|
+
printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"demo output"}]}]}}'
|
|
132
|
+
printf '%s\n' '{"type":"result","result":"Completed cli-process-service test"}'
|
|
133
|
+
printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
134
|
+
`
|
|
135
|
+
);
|
|
136
|
+
chmodSync(scriptPath, 0o755);
|
|
137
|
+
const stateDir = join(root, 'state');
|
|
138
|
+
const workFolder = join(root, 'work');
|
|
139
|
+
mkdirSync(workFolder, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const service = new CliProcessService({
|
|
142
|
+
stateDir,
|
|
143
|
+
cliPaths: {
|
|
144
|
+
claude: scriptPath,
|
|
145
|
+
codex: scriptPath,
|
|
146
|
+
gemini: scriptPath,
|
|
147
|
+
forge: scriptPath,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const runResult = await service.startProcess({
|
|
152
|
+
prompt: 'hello structured output',
|
|
153
|
+
cwd: workFolder,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const compactWait = await service.waitForProcesses([runResult.pid], 5);
|
|
157
|
+
expect(compactWait).toHaveLength(1);
|
|
158
|
+
expect(compactWait[0]).toMatchObject({
|
|
159
|
+
pid: runResult.pid,
|
|
160
|
+
agent: 'claude',
|
|
161
|
+
status: 'completed',
|
|
162
|
+
exitCode: null,
|
|
163
|
+
model: null,
|
|
164
|
+
session_id: 'session-cli-1',
|
|
165
|
+
agentOutput: {
|
|
166
|
+
message: 'Completed cli-process-service test',
|
|
167
|
+
session_id: 'session-cli-1',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
expect(compactWait[0]).not.toHaveProperty('startTime');
|
|
171
|
+
expect(compactWait[0]).not.toHaveProperty('workFolder');
|
|
172
|
+
expect(compactWait[0]).not.toHaveProperty('prompt');
|
|
173
|
+
expect(compactWait[0].agentOutput).not.toHaveProperty('tools');
|
|
174
|
+
|
|
175
|
+
const compactResult = await service.getProcessResult(runResult.pid, false);
|
|
176
|
+
expect(compactResult).toMatchObject({
|
|
177
|
+
pid: runResult.pid,
|
|
178
|
+
agent: 'claude',
|
|
179
|
+
status: 'completed',
|
|
180
|
+
exitCode: null,
|
|
181
|
+
model: null,
|
|
182
|
+
session_id: 'session-cli-1',
|
|
183
|
+
agentOutput: {
|
|
184
|
+
message: 'Completed cli-process-service test',
|
|
185
|
+
session_id: 'session-cli-1',
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
expect(compactResult).not.toHaveProperty('startTime');
|
|
189
|
+
expect(compactResult).not.toHaveProperty('workFolder');
|
|
190
|
+
expect(compactResult).not.toHaveProperty('prompt');
|
|
191
|
+
expect(compactResult.agentOutput).not.toHaveProperty('tools');
|
|
192
|
+
|
|
193
|
+
const verboseWait = await service.waitForProcesses([runResult.pid], 5, true);
|
|
194
|
+
expect(verboseWait).toHaveLength(1);
|
|
195
|
+
expect(verboseWait[0]).toMatchObject({
|
|
196
|
+
pid: runResult.pid,
|
|
197
|
+
agent: 'claude',
|
|
198
|
+
status: 'completed',
|
|
199
|
+
exitCode: null,
|
|
200
|
+
model: null,
|
|
201
|
+
startTime: expect.any(String),
|
|
202
|
+
workFolder,
|
|
203
|
+
prompt: 'hello structured output',
|
|
204
|
+
session_id: 'session-cli-1',
|
|
205
|
+
agentOutput: {
|
|
206
|
+
message: 'Completed cli-process-service test',
|
|
207
|
+
session_id: 'session-cli-1',
|
|
208
|
+
tools: [
|
|
209
|
+
{
|
|
210
|
+
tool: 'Read',
|
|
211
|
+
input: { file_path: '/tmp/demo.txt' },
|
|
212
|
+
output: 'demo output',
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const verboseResult = await service.getProcessResult(runResult.pid, true);
|
|
219
|
+
expect(verboseResult).toMatchObject({
|
|
220
|
+
pid: runResult.pid,
|
|
221
|
+
agent: 'claude',
|
|
222
|
+
status: 'completed',
|
|
223
|
+
exitCode: null,
|
|
224
|
+
model: null,
|
|
225
|
+
startTime: expect.any(String),
|
|
226
|
+
workFolder,
|
|
227
|
+
prompt: 'hello structured output',
|
|
228
|
+
session_id: 'session-cli-1',
|
|
229
|
+
agentOutput: {
|
|
230
|
+
message: 'Completed cli-process-service test',
|
|
231
|
+
session_id: 'session-cli-1',
|
|
232
|
+
tools: [
|
|
233
|
+
{
|
|
234
|
+
tool: 'Read',
|
|
235
|
+
input: { file_path: '/tmp/demo.txt' },
|
|
236
|
+
output: 'demo output',
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
104
243
|
it('can terminate a tracked process', async () => {
|
|
105
244
|
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
106
245
|
tempDirs.push(root);
|
|
@@ -195,6 +334,111 @@ describe('CliProcessService', () => {
|
|
|
195
334
|
killSpy.mockRestore();
|
|
196
335
|
});
|
|
197
336
|
|
|
337
|
+
it('lists processes without crashing when a tracked work folder has been deleted', async () => {
|
|
338
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
339
|
+
tempDirs.push(root);
|
|
340
|
+
const stateDir = join(root, 'state');
|
|
341
|
+
const workFolder = join(root, 'deleted-project');
|
|
342
|
+
mkdirSync(workFolder, { recursive: true });
|
|
343
|
+
|
|
344
|
+
const pid = 45678;
|
|
345
|
+
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
|
|
346
|
+
mkdirSync(processDir, { recursive: true });
|
|
347
|
+
|
|
348
|
+
writeFileSync(
|
|
349
|
+
join(processDir, 'meta.json'),
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
pid,
|
|
352
|
+
prompt: 'deleted cwd',
|
|
353
|
+
workFolder,
|
|
354
|
+
toolType: 'claude',
|
|
355
|
+
startTime: new Date().toISOString(),
|
|
356
|
+
stdoutPath: join(processDir, 'stdout.log'),
|
|
357
|
+
stderrPath: join(processDir, 'stderr.log'),
|
|
358
|
+
status: 'running',
|
|
359
|
+
})
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
rmSync(workFolder, { recursive: true, force: true });
|
|
363
|
+
|
|
364
|
+
const service = new CliProcessService({
|
|
365
|
+
stateDir,
|
|
366
|
+
cliPaths: {
|
|
367
|
+
claude: '/bin/sh',
|
|
368
|
+
codex: '/bin/sh',
|
|
369
|
+
gemini: '/bin/sh',
|
|
370
|
+
forge: '/bin/sh',
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target: number, signal?: string | number) => {
|
|
375
|
+
if (signal === 0 && target === pid) {
|
|
376
|
+
throw Object.assign(new Error('not running'), { code: 'ESRCH' });
|
|
377
|
+
}
|
|
378
|
+
return true;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const listed = await service.listProcesses();
|
|
382
|
+
|
|
383
|
+
expect(listed).toEqual([
|
|
384
|
+
{
|
|
385
|
+
pid,
|
|
386
|
+
agent: 'claude',
|
|
387
|
+
status: 'completed',
|
|
388
|
+
},
|
|
389
|
+
]);
|
|
390
|
+
expect(JSON.parse(readFileSync(join(processDir, 'meta.json'), 'utf-8')).status).toBe('completed');
|
|
391
|
+
killSpy.mockRestore();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('cleans up finished process directories even when their work folder has been deleted', async () => {
|
|
395
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
396
|
+
tempDirs.push(root);
|
|
397
|
+
const stateDir = join(root, 'state');
|
|
398
|
+
const workFolder = join(root, 'deleted-finished-project');
|
|
399
|
+
mkdirSync(workFolder, { recursive: true });
|
|
400
|
+
|
|
401
|
+
const pid = 56789;
|
|
402
|
+
const cwdDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)));
|
|
403
|
+
const processDir = join(cwdDir, String(pid));
|
|
404
|
+
mkdirSync(processDir, { recursive: true });
|
|
405
|
+
|
|
406
|
+
writeFileSync(
|
|
407
|
+
join(processDir, 'meta.json'),
|
|
408
|
+
JSON.stringify({
|
|
409
|
+
pid,
|
|
410
|
+
prompt: 'done',
|
|
411
|
+
workFolder,
|
|
412
|
+
toolType: 'claude',
|
|
413
|
+
startTime: new Date().toISOString(),
|
|
414
|
+
stdoutPath: join(processDir, 'stdout.log'),
|
|
415
|
+
stderrPath: join(processDir, 'stderr.log'),
|
|
416
|
+
status: 'completed',
|
|
417
|
+
})
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
rmSync(workFolder, { recursive: true, force: true });
|
|
421
|
+
|
|
422
|
+
const service = new CliProcessService({
|
|
423
|
+
stateDir,
|
|
424
|
+
cliPaths: {
|
|
425
|
+
claude: '/bin/sh',
|
|
426
|
+
codex: '/bin/sh',
|
|
427
|
+
gemini: '/bin/sh',
|
|
428
|
+
forge: '/bin/sh',
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const result = await service.cleanupProcesses();
|
|
433
|
+
|
|
434
|
+
expect(result).toEqual({
|
|
435
|
+
removed: 1,
|
|
436
|
+
message: 'Removed 1 processes',
|
|
437
|
+
});
|
|
438
|
+
expect(existsSync(processDir)).toBe(false);
|
|
439
|
+
expect(existsSync(cwdDir)).toBe(false);
|
|
440
|
+
});
|
|
441
|
+
|
|
198
442
|
it('cleans up completed and failed process directories but preserves running ones', async () => {
|
|
199
443
|
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
200
444
|
tempDirs.push(root);
|