ai-cli-mcp 2.19.0 → 2.20.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 +26 -0
- package/README.ja.md +34 -8
- package/README.md +41 -8
- package/dist/app/cli.js +1 -0
- package/dist/app/mcp.js +64 -12
- package/dist/cli-builder.js +13 -6
- package/dist/cli-process-service.js +76 -91
- package/dist/cli-utils.js +6 -0
- package/dist/cli.js +1 -1
- package/dist/model-catalog.js +3 -2
- package/dist/parsers.js +8 -2
- package/package.json +27 -3
- package/server.json +3 -3
- package/.gemini/settings.json +0 -11
- package/.github/dependabot.yml +0 -28
- package/.github/pull_request_template.md +0 -28
- package/.github/workflows/ci.yml +0 -34
- package/.github/workflows/dependency-review.yml +0 -22
- package/.github/workflows/publish.yml +0 -89
- package/.github/workflows/test.yml +0 -20
- package/.github/workflows/watch-session-prs.yml +0 -276
- package/.husky/pre-commit +0 -1
- package/.mcp.json +0 -11
- package/.releaserc.json +0 -18
- package/.vscode/settings.json +0 -3
- package/CONTRIBUTING.md +0 -81
- package/dist/__tests__/app-cli.test.js +0 -392
- package/dist/__tests__/cli-bin-smoke.test.js +0 -101
- package/dist/__tests__/cli-builder.test.js +0 -442
- package/dist/__tests__/cli-process-service.test.js +0 -655
- package/dist/__tests__/cli-utils.test.js +0 -171
- package/dist/__tests__/e2e.test.js +0 -256
- package/dist/__tests__/edge-cases.test.js +0 -130
- package/dist/__tests__/error-cases.test.js +0 -292
- package/dist/__tests__/mcp-contract.test.js +0 -636
- package/dist/__tests__/mocks.js +0 -32
- package/dist/__tests__/model-alias.test.js +0 -36
- package/dist/__tests__/parsers.test.js +0 -646
- package/dist/__tests__/peek.test.js +0 -36
- package/dist/__tests__/process-management.test.js +0 -949
- package/dist/__tests__/server.test.js +0 -809
- package/dist/__tests__/setup.js +0 -11
- package/dist/__tests__/utils/claude-mock.js +0 -80
- package/dist/__tests__/utils/mcp-client.js +0 -121
- package/dist/__tests__/utils/opencode-mock.js +0 -91
- package/dist/__tests__/utils/persistent-mock.js +0 -28
- package/dist/__tests__/utils/test-helpers.js +0 -11
- package/dist/__tests__/validation.test.js +0 -308
- package/dist/__tests__/version-print.test.js +0 -65
- package/dist/__tests__/wait.test.js +0 -260
- package/docs/RELEASE_CHECKLIST.md +0 -65
- package/docs/cli-architecture.md +0 -275
- package/docs/concept.md +0 -154
- package/docs/development.md +0 -156
- package/docs/e2e-testing.md +0 -148
- package/docs/prd.md +0 -146
- package/docs/session-stacking.md +0 -67
- package/src/__tests__/app-cli.test.ts +0 -495
- package/src/__tests__/cli-bin-smoke.test.ts +0 -136
- package/src/__tests__/cli-builder.test.ts +0 -549
- package/src/__tests__/cli-process-service.test.ts +0 -759
- package/src/__tests__/cli-utils.test.ts +0 -200
- package/src/__tests__/e2e.test.ts +0 -311
- package/src/__tests__/edge-cases.test.ts +0 -176
- package/src/__tests__/error-cases.test.ts +0 -370
- package/src/__tests__/mcp-contract.test.ts +0 -755
- package/src/__tests__/mocks.ts +0 -35
- package/src/__tests__/model-alias.test.ts +0 -44
- package/src/__tests__/parsers.test.ts +0 -730
- package/src/__tests__/peek.test.ts +0 -44
- package/src/__tests__/process-management.test.ts +0 -1129
- package/src/__tests__/server.test.ts +0 -1020
- package/src/__tests__/setup.ts +0 -13
- package/src/__tests__/utils/claude-mock.ts +0 -87
- package/src/__tests__/utils/mcp-client.ts +0 -159
- package/src/__tests__/utils/opencode-mock.ts +0 -108
- package/src/__tests__/utils/persistent-mock.ts +0 -33
- package/src/__tests__/utils/test-helpers.ts +0 -13
- package/src/__tests__/validation.test.ts +0 -369
- package/src/__tests__/version-print.test.ts +0 -81
- package/src/__tests__/wait.test.ts +0 -302
- package/src/app/cli.ts +0 -424
- package/src/app/mcp.ts +0 -466
- package/src/bin/ai-cli-mcp.ts +0 -7
- package/src/bin/ai-cli.ts +0 -11
- package/src/cli-builder.ts +0 -274
- package/src/cli-parse.ts +0 -105
- package/src/cli-process-service.ts +0 -709
- package/src/cli-utils.ts +0 -258
- package/src/cli.ts +0 -124
- package/src/model-catalog.ts +0 -87
- package/src/parsers.ts +0 -965
- package/src/peek.ts +0 -95
- package/src/process-result.ts +0 -88
- package/src/process-service.ts +0 -368
- package/src/server.ts +0 -10
- package/tsconfig.json +0 -16
- package/vitest.config.e2e.ts +0 -27
- package/vitest.config.ts +0 -22
- package/vitest.config.unit.ts +0 -28
|
@@ -1,655 +0,0 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import { CliProcessService } from '../cli-process-service.js';
|
|
6
|
-
import { createOpenCodeMock } from './utils/opencode-mock.js';
|
|
7
|
-
function createMockCliScript(dir, name, options = {}) {
|
|
8
|
-
const scriptPath = join(dir, name);
|
|
9
|
-
writeFileSync(scriptPath, `#!/bin/bash
|
|
10
|
-
prompt=""
|
|
11
|
-
while [[ $# -gt 0 ]]; do
|
|
12
|
-
case "$1" in
|
|
13
|
-
-p|--prompt)
|
|
14
|
-
prompt="$2"
|
|
15
|
-
shift 2
|
|
16
|
-
;;
|
|
17
|
-
*)
|
|
18
|
-
shift
|
|
19
|
-
;;
|
|
20
|
-
esac
|
|
21
|
-
done
|
|
22
|
-
|
|
23
|
-
${options.ignoreSigterm ? "trap '' TERM\n" : ''}
|
|
24
|
-
|
|
25
|
-
if [[ "$prompt" == *"sleep"* ]]; then
|
|
26
|
-
${options.ignoreSigterm ? ' while true; do sleep 1; done\n' : ' sleep 5\n'}
|
|
27
|
-
fi
|
|
28
|
-
|
|
29
|
-
echo "Command executed successfully"
|
|
30
|
-
`);
|
|
31
|
-
chmodSync(scriptPath, 0o755);
|
|
32
|
-
return scriptPath;
|
|
33
|
-
}
|
|
34
|
-
function encodeCwd(cwd) {
|
|
35
|
-
return cwd
|
|
36
|
-
.split('')
|
|
37
|
-
.map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
|
|
38
|
-
.join('');
|
|
39
|
-
}
|
|
40
|
-
describe('CliProcessService', () => {
|
|
41
|
-
const tempDirs = [];
|
|
42
|
-
afterEach(() => {
|
|
43
|
-
for (const dir of tempDirs.splice(0)) {
|
|
44
|
-
rmSync(dir, { recursive: true, force: true });
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
it('starts a detached process and persists state under a normalized cwd directory', async () => {
|
|
48
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
49
|
-
tempDirs.push(root);
|
|
50
|
-
const scriptPath = createMockCliScript(root, 'mock-claude');
|
|
51
|
-
const stateDir = join(root, 'state');
|
|
52
|
-
const workFolder = join(root, 'work');
|
|
53
|
-
mkdirSync(workFolder, { recursive: true });
|
|
54
|
-
const service = new CliProcessService({
|
|
55
|
-
stateDir,
|
|
56
|
-
cliPaths: {
|
|
57
|
-
claude: scriptPath,
|
|
58
|
-
codex: scriptPath,
|
|
59
|
-
gemini: scriptPath,
|
|
60
|
-
forge: scriptPath,
|
|
61
|
-
opencode: scriptPath,
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
const runResult = await service.startProcess({
|
|
65
|
-
prompt: 'hello',
|
|
66
|
-
cwd: workFolder,
|
|
67
|
-
model: 'sonnet',
|
|
68
|
-
});
|
|
69
|
-
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
|
|
70
|
-
expect(runResult.pid).toBeGreaterThan(0);
|
|
71
|
-
expect(runResult.status).toBe('started');
|
|
72
|
-
expect(existsSync(join(processDir, 'meta.json'))).toBe(true);
|
|
73
|
-
expect(existsSync(join(processDir, 'stdout.log'))).toBe(true);
|
|
74
|
-
expect(existsSync(join(processDir, 'stderr.log'))).toBe(true);
|
|
75
|
-
const waitResult = await service.waitForProcesses([runResult.pid], 5);
|
|
76
|
-
expect(waitResult).toHaveLength(1);
|
|
77
|
-
expect(waitResult[0]).toMatchObject({
|
|
78
|
-
pid: runResult.pid,
|
|
79
|
-
agent: 'claude',
|
|
80
|
-
status: 'completed',
|
|
81
|
-
exitCode: null,
|
|
82
|
-
model: 'sonnet',
|
|
83
|
-
stdout: expect.any(String),
|
|
84
|
-
stderr: expect.any(String),
|
|
85
|
-
});
|
|
86
|
-
expect(waitResult[0]).not.toHaveProperty('startTime');
|
|
87
|
-
expect(waitResult[0]).not.toHaveProperty('workFolder');
|
|
88
|
-
expect(waitResult[0]).not.toHaveProperty('prompt');
|
|
89
|
-
const listed = await service.listProcesses();
|
|
90
|
-
expect(listed).toContainEqual({
|
|
91
|
-
pid: runResult.pid,
|
|
92
|
-
agent: 'claude',
|
|
93
|
-
status: 'completed',
|
|
94
|
-
});
|
|
95
|
-
const result = await service.getProcessResult(runResult.pid, false);
|
|
96
|
-
expect(result).toMatchObject({
|
|
97
|
-
pid: runResult.pid,
|
|
98
|
-
agent: 'claude',
|
|
99
|
-
status: 'completed',
|
|
100
|
-
exitCode: null,
|
|
101
|
-
model: 'sonnet',
|
|
102
|
-
stdout: expect.stringContaining('Command executed successfully'),
|
|
103
|
-
stderr: expect.any(String),
|
|
104
|
-
});
|
|
105
|
-
expect(result).not.toHaveProperty('startTime');
|
|
106
|
-
expect(result).not.toHaveProperty('workFolder');
|
|
107
|
-
expect(result).not.toHaveProperty('prompt');
|
|
108
|
-
expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
|
|
109
|
-
});
|
|
110
|
-
it('peeks only appended natural-language messages from detached logs', async () => {
|
|
111
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
112
|
-
tempDirs.push(root);
|
|
113
|
-
const scriptPath = join(root, 'mock-claude-peek');
|
|
114
|
-
writeFileSync(scriptPath, `#!/bin/bash
|
|
115
|
-
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
|
|
116
|
-
sleep 2
|
|
117
|
-
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"new cli message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}'
|
|
118
|
-
printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
|
|
119
|
-
`);
|
|
120
|
-
chmodSync(scriptPath, 0o755);
|
|
121
|
-
const stateDir = join(root, 'state');
|
|
122
|
-
const workFolder = join(root, 'work');
|
|
123
|
-
mkdirSync(workFolder, { recursive: true });
|
|
124
|
-
const service = new CliProcessService({
|
|
125
|
-
stateDir,
|
|
126
|
-
cliPaths: {
|
|
127
|
-
claude: scriptPath,
|
|
128
|
-
codex: scriptPath,
|
|
129
|
-
gemini: scriptPath,
|
|
130
|
-
forge: scriptPath,
|
|
131
|
-
opencode: scriptPath,
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
const runResult = await service.startProcess({
|
|
135
|
-
prompt: 'hello peek',
|
|
136
|
-
cwd: workFolder,
|
|
137
|
-
});
|
|
138
|
-
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
|
|
139
|
-
const stdoutPath = join(processDir, 'stdout.log');
|
|
140
|
-
const startedAt = Date.now();
|
|
141
|
-
while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
|
|
142
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
143
|
-
}
|
|
144
|
-
expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
|
|
145
|
-
const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
|
|
146
|
-
expect(peekResult.processes).toHaveLength(2);
|
|
147
|
-
expect(peekResult.processes[0]).toMatchObject({
|
|
148
|
-
pid: runResult.pid,
|
|
149
|
-
agent: 'claude',
|
|
150
|
-
status: 'completed',
|
|
151
|
-
events: [
|
|
152
|
-
{
|
|
153
|
-
kind: 'message',
|
|
154
|
-
ts: expect.any(String),
|
|
155
|
-
text: 'new cli message',
|
|
156
|
-
},
|
|
157
|
-
],
|
|
158
|
-
truncated: false,
|
|
159
|
-
error: null,
|
|
160
|
-
});
|
|
161
|
-
expect(peekResult.processes[1]).toEqual({
|
|
162
|
-
pid: 999999,
|
|
163
|
-
agent: null,
|
|
164
|
-
status: 'not_found',
|
|
165
|
-
events: [],
|
|
166
|
-
truncated: false,
|
|
167
|
-
error: 'process not found',
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
it('returns compact results by default and full results when verbose is true', async () => {
|
|
171
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
172
|
-
tempDirs.push(root);
|
|
173
|
-
const scriptPath = join(root, 'mock-claude-json');
|
|
174
|
-
writeFileSync(scriptPath, `#!/bin/bash
|
|
175
|
-
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/demo.txt"}}]}}'
|
|
176
|
-
printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"demo output"}]}]}}'
|
|
177
|
-
printf '%s\n' '{"type":"result","result":"Completed cli-process-service test"}'
|
|
178
|
-
printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
179
|
-
`);
|
|
180
|
-
chmodSync(scriptPath, 0o755);
|
|
181
|
-
const stateDir = join(root, 'state');
|
|
182
|
-
const workFolder = join(root, 'work');
|
|
183
|
-
mkdirSync(workFolder, { recursive: true });
|
|
184
|
-
const service = new CliProcessService({
|
|
185
|
-
stateDir,
|
|
186
|
-
cliPaths: {
|
|
187
|
-
claude: scriptPath,
|
|
188
|
-
codex: scriptPath,
|
|
189
|
-
gemini: scriptPath,
|
|
190
|
-
forge: scriptPath,
|
|
191
|
-
opencode: scriptPath,
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
const runResult = await service.startProcess({
|
|
195
|
-
prompt: 'hello structured output',
|
|
196
|
-
cwd: workFolder,
|
|
197
|
-
});
|
|
198
|
-
const compactWait = await service.waitForProcesses([runResult.pid], 5);
|
|
199
|
-
expect(compactWait).toHaveLength(1);
|
|
200
|
-
expect(compactWait[0]).toMatchObject({
|
|
201
|
-
pid: runResult.pid,
|
|
202
|
-
agent: 'claude',
|
|
203
|
-
status: 'completed',
|
|
204
|
-
exitCode: null,
|
|
205
|
-
model: null,
|
|
206
|
-
session_id: 'session-cli-1',
|
|
207
|
-
agentOutput: {
|
|
208
|
-
message: 'Completed cli-process-service test',
|
|
209
|
-
session_id: 'session-cli-1',
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
expect(compactWait[0]).not.toHaveProperty('startTime');
|
|
213
|
-
expect(compactWait[0]).not.toHaveProperty('workFolder');
|
|
214
|
-
expect(compactWait[0]).not.toHaveProperty('prompt');
|
|
215
|
-
expect(compactWait[0].agentOutput).not.toHaveProperty('tools');
|
|
216
|
-
const compactResult = await service.getProcessResult(runResult.pid, false);
|
|
217
|
-
expect(compactResult).toMatchObject({
|
|
218
|
-
pid: runResult.pid,
|
|
219
|
-
agent: 'claude',
|
|
220
|
-
status: 'completed',
|
|
221
|
-
exitCode: null,
|
|
222
|
-
model: null,
|
|
223
|
-
session_id: 'session-cli-1',
|
|
224
|
-
agentOutput: {
|
|
225
|
-
message: 'Completed cli-process-service test',
|
|
226
|
-
session_id: 'session-cli-1',
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
expect(compactResult).not.toHaveProperty('startTime');
|
|
230
|
-
expect(compactResult).not.toHaveProperty('workFolder');
|
|
231
|
-
expect(compactResult).not.toHaveProperty('prompt');
|
|
232
|
-
expect(compactResult.agentOutput).not.toHaveProperty('tools');
|
|
233
|
-
const verboseWait = await service.waitForProcesses([runResult.pid], 5, true);
|
|
234
|
-
expect(verboseWait).toHaveLength(1);
|
|
235
|
-
expect(verboseWait[0]).toMatchObject({
|
|
236
|
-
pid: runResult.pid,
|
|
237
|
-
agent: 'claude',
|
|
238
|
-
status: 'completed',
|
|
239
|
-
exitCode: null,
|
|
240
|
-
model: null,
|
|
241
|
-
startTime: expect.any(String),
|
|
242
|
-
workFolder,
|
|
243
|
-
prompt: 'hello structured output',
|
|
244
|
-
session_id: 'session-cli-1',
|
|
245
|
-
agentOutput: {
|
|
246
|
-
message: 'Completed cli-process-service test',
|
|
247
|
-
session_id: 'session-cli-1',
|
|
248
|
-
tools: [
|
|
249
|
-
{
|
|
250
|
-
tool: 'Read',
|
|
251
|
-
input: { file_path: '/tmp/demo.txt' },
|
|
252
|
-
output: 'demo output',
|
|
253
|
-
},
|
|
254
|
-
],
|
|
255
|
-
},
|
|
256
|
-
});
|
|
257
|
-
const verboseResult = await service.getProcessResult(runResult.pid, true);
|
|
258
|
-
expect(verboseResult).toMatchObject({
|
|
259
|
-
pid: runResult.pid,
|
|
260
|
-
agent: 'claude',
|
|
261
|
-
status: 'completed',
|
|
262
|
-
exitCode: null,
|
|
263
|
-
model: null,
|
|
264
|
-
startTime: expect.any(String),
|
|
265
|
-
workFolder,
|
|
266
|
-
prompt: 'hello structured output',
|
|
267
|
-
session_id: 'session-cli-1',
|
|
268
|
-
agentOutput: {
|
|
269
|
-
message: 'Completed cli-process-service test',
|
|
270
|
-
session_id: 'session-cli-1',
|
|
271
|
-
tools: [
|
|
272
|
-
{
|
|
273
|
-
tool: 'Read',
|
|
274
|
-
input: { file_path: '/tmp/demo.txt' },
|
|
275
|
-
output: 'demo output',
|
|
276
|
-
},
|
|
277
|
-
],
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
it('can terminate a tracked process', async () => {
|
|
282
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
283
|
-
tempDirs.push(root);
|
|
284
|
-
const scriptPath = createMockCliScript(root, 'mock-claude');
|
|
285
|
-
const stateDir = join(root, 'state');
|
|
286
|
-
const workFolder = join(root, 'work');
|
|
287
|
-
mkdirSync(workFolder, { recursive: true });
|
|
288
|
-
const service = new CliProcessService({
|
|
289
|
-
stateDir,
|
|
290
|
-
cliPaths: {
|
|
291
|
-
claude: scriptPath,
|
|
292
|
-
codex: scriptPath,
|
|
293
|
-
gemini: scriptPath,
|
|
294
|
-
forge: scriptPath,
|
|
295
|
-
opencode: scriptPath,
|
|
296
|
-
},
|
|
297
|
-
});
|
|
298
|
-
const runResult = await service.startProcess({
|
|
299
|
-
prompt: 'sleep please',
|
|
300
|
-
cwd: workFolder,
|
|
301
|
-
model: 'sonnet',
|
|
302
|
-
});
|
|
303
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
304
|
-
const killResult = await service.killProcess(runResult.pid);
|
|
305
|
-
expect(killResult).toEqual({
|
|
306
|
-
pid: runResult.pid,
|
|
307
|
-
status: 'terminated',
|
|
308
|
-
message: 'Process terminated successfully',
|
|
309
|
-
});
|
|
310
|
-
const result = await service.getProcessResult(runResult.pid, false);
|
|
311
|
-
expect(result.status).toBe('failed');
|
|
312
|
-
});
|
|
313
|
-
it('does not report termination until the process actually exits', async () => {
|
|
314
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
315
|
-
tempDirs.push(root);
|
|
316
|
-
const stateDir = join(root, 'state');
|
|
317
|
-
const workFolder = join(root, 'project');
|
|
318
|
-
mkdirSync(workFolder, { recursive: true });
|
|
319
|
-
const pid = 12345;
|
|
320
|
-
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
|
|
321
|
-
mkdirSync(processDir, { recursive: true });
|
|
322
|
-
const service = new CliProcessService({
|
|
323
|
-
stateDir,
|
|
324
|
-
cliPaths: {
|
|
325
|
-
claude: '/bin/sh',
|
|
326
|
-
codex: '/bin/sh',
|
|
327
|
-
gemini: '/bin/sh',
|
|
328
|
-
forge: '/bin/sh',
|
|
329
|
-
opencode: '/bin/sh',
|
|
330
|
-
},
|
|
331
|
-
});
|
|
332
|
-
writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
|
|
333
|
-
pid,
|
|
334
|
-
prompt: 'sleep please',
|
|
335
|
-
workFolder,
|
|
336
|
-
model: 'sonnet',
|
|
337
|
-
toolType: 'claude',
|
|
338
|
-
startTime: new Date().toISOString(),
|
|
339
|
-
stdoutPath: join(processDir, 'stdout.log'),
|
|
340
|
-
stderrPath: join(processDir, 'stderr.log'),
|
|
341
|
-
status: 'running',
|
|
342
|
-
}));
|
|
343
|
-
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
|
|
344
|
-
if (signal === 0) {
|
|
345
|
-
return true;
|
|
346
|
-
}
|
|
347
|
-
if (target === -pid && signal === 'SIGTERM') {
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
350
|
-
return true;
|
|
351
|
-
});
|
|
352
|
-
const killResult = await service.killProcess(pid);
|
|
353
|
-
expect(killResult).toEqual({
|
|
354
|
-
pid,
|
|
355
|
-
status: 'running',
|
|
356
|
-
message: 'Signal sent but process is still running',
|
|
357
|
-
});
|
|
358
|
-
const stored = JSON.parse(readFileSync(join(processDir, 'meta.json'), 'utf-8'));
|
|
359
|
-
expect(stored.status).toBe('running');
|
|
360
|
-
killSpy.mockRestore();
|
|
361
|
-
});
|
|
362
|
-
it('lists processes without crashing when a tracked work folder has been deleted', async () => {
|
|
363
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
364
|
-
tempDirs.push(root);
|
|
365
|
-
const stateDir = join(root, 'state');
|
|
366
|
-
const workFolder = join(root, 'deleted-project');
|
|
367
|
-
mkdirSync(workFolder, { recursive: true });
|
|
368
|
-
const pid = 45678;
|
|
369
|
-
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
|
|
370
|
-
mkdirSync(processDir, { recursive: true });
|
|
371
|
-
writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
|
|
372
|
-
pid,
|
|
373
|
-
prompt: 'deleted cwd',
|
|
374
|
-
workFolder,
|
|
375
|
-
toolType: 'claude',
|
|
376
|
-
startTime: new Date().toISOString(),
|
|
377
|
-
stdoutPath: join(processDir, 'stdout.log'),
|
|
378
|
-
stderrPath: join(processDir, 'stderr.log'),
|
|
379
|
-
status: 'running',
|
|
380
|
-
}));
|
|
381
|
-
rmSync(workFolder, { recursive: true, force: true });
|
|
382
|
-
const service = new CliProcessService({
|
|
383
|
-
stateDir,
|
|
384
|
-
cliPaths: {
|
|
385
|
-
claude: '/bin/sh',
|
|
386
|
-
codex: '/bin/sh',
|
|
387
|
-
gemini: '/bin/sh',
|
|
388
|
-
forge: '/bin/sh',
|
|
389
|
-
opencode: '/bin/sh',
|
|
390
|
-
},
|
|
391
|
-
});
|
|
392
|
-
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
|
|
393
|
-
if (signal === 0 && target === pid) {
|
|
394
|
-
throw Object.assign(new Error('not running'), { code: 'ESRCH' });
|
|
395
|
-
}
|
|
396
|
-
return true;
|
|
397
|
-
});
|
|
398
|
-
const listed = await service.listProcesses();
|
|
399
|
-
expect(listed).toEqual([
|
|
400
|
-
{
|
|
401
|
-
pid,
|
|
402
|
-
agent: 'claude',
|
|
403
|
-
status: 'completed',
|
|
404
|
-
},
|
|
405
|
-
]);
|
|
406
|
-
expect(JSON.parse(readFileSync(join(processDir, 'meta.json'), 'utf-8')).status).toBe('completed');
|
|
407
|
-
killSpy.mockRestore();
|
|
408
|
-
});
|
|
409
|
-
it('cleans up finished process directories even when their work folder has been deleted', async () => {
|
|
410
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
411
|
-
tempDirs.push(root);
|
|
412
|
-
const stateDir = join(root, 'state');
|
|
413
|
-
const workFolder = join(root, 'deleted-finished-project');
|
|
414
|
-
mkdirSync(workFolder, { recursive: true });
|
|
415
|
-
const pid = 56789;
|
|
416
|
-
const cwdDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)));
|
|
417
|
-
const processDir = join(cwdDir, String(pid));
|
|
418
|
-
mkdirSync(processDir, { recursive: true });
|
|
419
|
-
writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
|
|
420
|
-
pid,
|
|
421
|
-
prompt: 'done',
|
|
422
|
-
workFolder,
|
|
423
|
-
toolType: 'claude',
|
|
424
|
-
startTime: new Date().toISOString(),
|
|
425
|
-
stdoutPath: join(processDir, 'stdout.log'),
|
|
426
|
-
stderrPath: join(processDir, 'stderr.log'),
|
|
427
|
-
status: 'completed',
|
|
428
|
-
}));
|
|
429
|
-
rmSync(workFolder, { recursive: true, force: true });
|
|
430
|
-
const service = new CliProcessService({
|
|
431
|
-
stateDir,
|
|
432
|
-
cliPaths: {
|
|
433
|
-
claude: '/bin/sh',
|
|
434
|
-
codex: '/bin/sh',
|
|
435
|
-
gemini: '/bin/sh',
|
|
436
|
-
forge: '/bin/sh',
|
|
437
|
-
opencode: '/bin/sh',
|
|
438
|
-
},
|
|
439
|
-
});
|
|
440
|
-
const result = await service.cleanupProcesses();
|
|
441
|
-
expect(result).toEqual({
|
|
442
|
-
removed: 1,
|
|
443
|
-
message: 'Removed 1 processes',
|
|
444
|
-
});
|
|
445
|
-
expect(existsSync(processDir)).toBe(false);
|
|
446
|
-
expect(existsSync(cwdDir)).toBe(false);
|
|
447
|
-
});
|
|
448
|
-
it('cleans up completed and failed process directories but preserves running ones', async () => {
|
|
449
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
450
|
-
tempDirs.push(root);
|
|
451
|
-
const stateDir = join(root, 'state');
|
|
452
|
-
const runningCwd = join(root, 'running-project');
|
|
453
|
-
const finishedCwd = join(root, 'finished-project');
|
|
454
|
-
mkdirSync(runningCwd, { recursive: true });
|
|
455
|
-
mkdirSync(finishedCwd, { recursive: true });
|
|
456
|
-
const runningDir = join(stateDir, 'cwds', encodeCwd(realpathSync(runningCwd)), '111');
|
|
457
|
-
const completedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '222');
|
|
458
|
-
const failedDir = join(stateDir, 'cwds', encodeCwd(realpathSync(finishedCwd)), '333');
|
|
459
|
-
mkdirSync(runningDir, { recursive: true });
|
|
460
|
-
mkdirSync(completedDir, { recursive: true });
|
|
461
|
-
mkdirSync(failedDir, { recursive: true });
|
|
462
|
-
writeFileSync(join(runningDir, 'meta.json'), JSON.stringify({
|
|
463
|
-
pid: 111,
|
|
464
|
-
prompt: 'keep',
|
|
465
|
-
workFolder: runningCwd,
|
|
466
|
-
toolType: 'claude',
|
|
467
|
-
startTime: new Date().toISOString(),
|
|
468
|
-
stdoutPath: join(runningDir, 'stdout.log'),
|
|
469
|
-
stderrPath: join(runningDir, 'stderr.log'),
|
|
470
|
-
status: 'running',
|
|
471
|
-
}));
|
|
472
|
-
writeFileSync(join(completedDir, 'meta.json'), JSON.stringify({
|
|
473
|
-
pid: 222,
|
|
474
|
-
prompt: 'done',
|
|
475
|
-
workFolder: finishedCwd,
|
|
476
|
-
toolType: 'claude',
|
|
477
|
-
startTime: new Date().toISOString(),
|
|
478
|
-
stdoutPath: join(completedDir, 'stdout.log'),
|
|
479
|
-
stderrPath: join(completedDir, 'stderr.log'),
|
|
480
|
-
status: 'completed',
|
|
481
|
-
}));
|
|
482
|
-
writeFileSync(join(failedDir, 'meta.json'), JSON.stringify({
|
|
483
|
-
pid: 333,
|
|
484
|
-
prompt: 'failed',
|
|
485
|
-
workFolder: finishedCwd,
|
|
486
|
-
toolType: 'claude',
|
|
487
|
-
startTime: new Date().toISOString(),
|
|
488
|
-
stdoutPath: join(failedDir, 'stdout.log'),
|
|
489
|
-
stderrPath: join(failedDir, 'stderr.log'),
|
|
490
|
-
status: 'failed',
|
|
491
|
-
}));
|
|
492
|
-
const service = new CliProcessService({
|
|
493
|
-
stateDir,
|
|
494
|
-
cliPaths: {
|
|
495
|
-
claude: '/bin/sh',
|
|
496
|
-
codex: '/bin/sh',
|
|
497
|
-
gemini: '/bin/sh',
|
|
498
|
-
forge: '/bin/sh',
|
|
499
|
-
opencode: '/bin/sh',
|
|
500
|
-
},
|
|
501
|
-
});
|
|
502
|
-
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
|
|
503
|
-
if (signal === 0 && target === 111) {
|
|
504
|
-
return true;
|
|
505
|
-
}
|
|
506
|
-
throw Object.assign(new Error('not running'), { code: 'ESRCH' });
|
|
507
|
-
});
|
|
508
|
-
const result = await service.cleanupProcesses();
|
|
509
|
-
expect(result).toEqual({
|
|
510
|
-
removed: 2,
|
|
511
|
-
message: 'Removed 2 processes',
|
|
512
|
-
});
|
|
513
|
-
expect(existsSync(runningDir)).toBe(true);
|
|
514
|
-
expect(existsSync(completedDir)).toBe(false);
|
|
515
|
-
expect(existsSync(failedDir)).toBe(false);
|
|
516
|
-
killSpy.mockRestore();
|
|
517
|
-
});
|
|
518
|
-
it('parses forge output from detached process logs', async () => {
|
|
519
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
520
|
-
tempDirs.push(root);
|
|
521
|
-
const stateDir = join(root, 'state');
|
|
522
|
-
const workFolder = join(root, 'forge-project');
|
|
523
|
-
mkdirSync(workFolder, { recursive: true });
|
|
524
|
-
const pid = 54321;
|
|
525
|
-
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
|
|
526
|
-
mkdirSync(processDir, { recursive: true });
|
|
527
|
-
writeFileSync(join(processDir, 'stdout.log'), `● [21:09:01] Initialize forge-conv-1
|
|
528
|
-
Forge assistant reply
|
|
529
|
-
● [21:09:08] Finished forge-conv-1
|
|
530
|
-
`);
|
|
531
|
-
writeFileSync(join(processDir, 'stderr.log'), '');
|
|
532
|
-
writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
|
|
533
|
-
pid,
|
|
534
|
-
prompt: 'hello forge',
|
|
535
|
-
workFolder,
|
|
536
|
-
model: 'forge',
|
|
537
|
-
toolType: 'forge',
|
|
538
|
-
startTime: new Date().toISOString(),
|
|
539
|
-
stdoutPath: join(processDir, 'stdout.log'),
|
|
540
|
-
stderrPath: join(processDir, 'stderr.log'),
|
|
541
|
-
status: 'completed',
|
|
542
|
-
}));
|
|
543
|
-
const service = new CliProcessService({
|
|
544
|
-
stateDir,
|
|
545
|
-
cliPaths: {
|
|
546
|
-
claude: '/bin/sh',
|
|
547
|
-
codex: '/bin/sh',
|
|
548
|
-
gemini: '/bin/sh',
|
|
549
|
-
forge: '/bin/sh',
|
|
550
|
-
opencode: '/bin/sh',
|
|
551
|
-
},
|
|
552
|
-
});
|
|
553
|
-
const result = await service.getProcessResult(pid, false);
|
|
554
|
-
expect(result.agent).toBe('forge');
|
|
555
|
-
expect(result.session_id).toBe('forge-conv-1');
|
|
556
|
-
expect(result.agentOutput).toEqual({
|
|
557
|
-
message: 'Forge assistant reply',
|
|
558
|
-
session_id: 'forge-conv-1',
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
it('parses successful OpenCode detached runs from stdout only', async () => {
|
|
562
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
563
|
-
tempDirs.push(root);
|
|
564
|
-
const stateDir = join(root, 'state');
|
|
565
|
-
const workFolder = join(root, 'opencode-project');
|
|
566
|
-
mkdirSync(workFolder, { recursive: true });
|
|
567
|
-
const argsLogPath = join(root, 'opencode-args.log');
|
|
568
|
-
const { scriptPath } = createOpenCodeMock(root, { argsLogPath });
|
|
569
|
-
const service = new CliProcessService({
|
|
570
|
-
stateDir,
|
|
571
|
-
cliPaths: {
|
|
572
|
-
claude: '/bin/sh',
|
|
573
|
-
codex: '/bin/sh',
|
|
574
|
-
gemini: '/bin/sh',
|
|
575
|
-
forge: '/bin/sh',
|
|
576
|
-
opencode: scriptPath,
|
|
577
|
-
},
|
|
578
|
-
});
|
|
579
|
-
const runResult = await service.startProcess({
|
|
580
|
-
prompt: 'hello opencode',
|
|
581
|
-
cwd: workFolder,
|
|
582
|
-
model: 'opencode',
|
|
583
|
-
});
|
|
584
|
-
const waited = await service.waitForProcesses([runResult.pid], 5);
|
|
585
|
-
expect(waited).toHaveLength(1);
|
|
586
|
-
expect(waited[0]).toMatchObject({
|
|
587
|
-
pid: runResult.pid,
|
|
588
|
-
agent: 'opencode',
|
|
589
|
-
status: 'completed',
|
|
590
|
-
exitCode: 0,
|
|
591
|
-
model: 'opencode',
|
|
592
|
-
session_id: 'ses-opencode-default',
|
|
593
|
-
agentOutput: {
|
|
594
|
-
message: 'Initial: hello opencode',
|
|
595
|
-
session_id: 'ses-opencode-default',
|
|
596
|
-
tokens: { total: 11833 },
|
|
597
|
-
cost: 0,
|
|
598
|
-
},
|
|
599
|
-
});
|
|
600
|
-
expect(waited[0]).not.toHaveProperty('stdout');
|
|
601
|
-
expect(waited[0]).not.toHaveProperty('stderr');
|
|
602
|
-
expect(readFileSync(argsLogPath, 'utf8')).toContain(`--dir ${workFolder}`);
|
|
603
|
-
});
|
|
604
|
-
it('preserves raw stdout and stderr for failed detached OpenCode runs', async () => {
|
|
605
|
-
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
606
|
-
tempDirs.push(root);
|
|
607
|
-
const stateDir = join(root, 'state');
|
|
608
|
-
const workFolder = join(root, 'opencode-fail-project');
|
|
609
|
-
mkdirSync(workFolder, { recursive: true });
|
|
610
|
-
const { scriptPath } = createOpenCodeMock(root);
|
|
611
|
-
const service = new CliProcessService({
|
|
612
|
-
stateDir,
|
|
613
|
-
cliPaths: {
|
|
614
|
-
claude: '/bin/sh',
|
|
615
|
-
codex: '/bin/sh',
|
|
616
|
-
gemini: '/bin/sh',
|
|
617
|
-
forge: '/bin/sh',
|
|
618
|
-
opencode: scriptPath,
|
|
619
|
-
},
|
|
620
|
-
});
|
|
621
|
-
const runResult = await service.startProcess({
|
|
622
|
-
prompt: 'please fail',
|
|
623
|
-
cwd: workFolder,
|
|
624
|
-
model: 'oc-openai/gpt-5.4',
|
|
625
|
-
});
|
|
626
|
-
const [compactResult] = await service.waitForProcesses([runResult.pid], 5);
|
|
627
|
-
expect(compactResult).toMatchObject({
|
|
628
|
-
pid: runResult.pid,
|
|
629
|
-
agent: 'opencode',
|
|
630
|
-
status: 'failed',
|
|
631
|
-
exitCode: 7,
|
|
632
|
-
model: 'oc-openai/gpt-5.4',
|
|
633
|
-
session_id: 'ses-opencode-default',
|
|
634
|
-
stdout: expect.stringContaining('Partial failure output'),
|
|
635
|
-
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
636
|
-
});
|
|
637
|
-
expect(compactResult).not.toHaveProperty('agentOutput');
|
|
638
|
-
const verboseResult = await service.getProcessResult(runResult.pid, true);
|
|
639
|
-
expect(verboseResult).toMatchObject({
|
|
640
|
-
pid: runResult.pid,
|
|
641
|
-
agent: 'opencode',
|
|
642
|
-
status: 'failed',
|
|
643
|
-
exitCode: 7,
|
|
644
|
-
session_id: 'ses-opencode-default',
|
|
645
|
-
stdout: expect.stringContaining('Partial failure output'),
|
|
646
|
-
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
647
|
-
agentOutput: {
|
|
648
|
-
message: 'Partial failure output',
|
|
649
|
-
session_id: 'ses-opencode-default',
|
|
650
|
-
tokens: { total: 42 },
|
|
651
|
-
cost: 0,
|
|
652
|
-
},
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
});
|