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.
Files changed (100) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.ja.md +34 -8
  3. package/README.md +41 -8
  4. package/dist/app/cli.js +1 -0
  5. package/dist/app/mcp.js +64 -12
  6. package/dist/cli-builder.js +13 -6
  7. package/dist/cli-process-service.js +76 -91
  8. package/dist/cli-utils.js +6 -0
  9. package/dist/cli.js +1 -1
  10. package/dist/model-catalog.js +3 -2
  11. package/dist/parsers.js +8 -2
  12. package/package.json +27 -3
  13. package/server.json +3 -3
  14. package/.gemini/settings.json +0 -11
  15. package/.github/dependabot.yml +0 -28
  16. package/.github/pull_request_template.md +0 -28
  17. package/.github/workflows/ci.yml +0 -34
  18. package/.github/workflows/dependency-review.yml +0 -22
  19. package/.github/workflows/publish.yml +0 -89
  20. package/.github/workflows/test.yml +0 -20
  21. package/.github/workflows/watch-session-prs.yml +0 -276
  22. package/.husky/pre-commit +0 -1
  23. package/.mcp.json +0 -11
  24. package/.releaserc.json +0 -18
  25. package/.vscode/settings.json +0 -3
  26. package/CONTRIBUTING.md +0 -81
  27. package/dist/__tests__/app-cli.test.js +0 -392
  28. package/dist/__tests__/cli-bin-smoke.test.js +0 -101
  29. package/dist/__tests__/cli-builder.test.js +0 -442
  30. package/dist/__tests__/cli-process-service.test.js +0 -655
  31. package/dist/__tests__/cli-utils.test.js +0 -171
  32. package/dist/__tests__/e2e.test.js +0 -256
  33. package/dist/__tests__/edge-cases.test.js +0 -130
  34. package/dist/__tests__/error-cases.test.js +0 -292
  35. package/dist/__tests__/mcp-contract.test.js +0 -636
  36. package/dist/__tests__/mocks.js +0 -32
  37. package/dist/__tests__/model-alias.test.js +0 -36
  38. package/dist/__tests__/parsers.test.js +0 -646
  39. package/dist/__tests__/peek.test.js +0 -36
  40. package/dist/__tests__/process-management.test.js +0 -949
  41. package/dist/__tests__/server.test.js +0 -809
  42. package/dist/__tests__/setup.js +0 -11
  43. package/dist/__tests__/utils/claude-mock.js +0 -80
  44. package/dist/__tests__/utils/mcp-client.js +0 -121
  45. package/dist/__tests__/utils/opencode-mock.js +0 -91
  46. package/dist/__tests__/utils/persistent-mock.js +0 -28
  47. package/dist/__tests__/utils/test-helpers.js +0 -11
  48. package/dist/__tests__/validation.test.js +0 -308
  49. package/dist/__tests__/version-print.test.js +0 -65
  50. package/dist/__tests__/wait.test.js +0 -260
  51. package/docs/RELEASE_CHECKLIST.md +0 -65
  52. package/docs/cli-architecture.md +0 -275
  53. package/docs/concept.md +0 -154
  54. package/docs/development.md +0 -156
  55. package/docs/e2e-testing.md +0 -148
  56. package/docs/prd.md +0 -146
  57. package/docs/session-stacking.md +0 -67
  58. package/src/__tests__/app-cli.test.ts +0 -495
  59. package/src/__tests__/cli-bin-smoke.test.ts +0 -136
  60. package/src/__tests__/cli-builder.test.ts +0 -549
  61. package/src/__tests__/cli-process-service.test.ts +0 -759
  62. package/src/__tests__/cli-utils.test.ts +0 -200
  63. package/src/__tests__/e2e.test.ts +0 -311
  64. package/src/__tests__/edge-cases.test.ts +0 -176
  65. package/src/__tests__/error-cases.test.ts +0 -370
  66. package/src/__tests__/mcp-contract.test.ts +0 -755
  67. package/src/__tests__/mocks.ts +0 -35
  68. package/src/__tests__/model-alias.test.ts +0 -44
  69. package/src/__tests__/parsers.test.ts +0 -730
  70. package/src/__tests__/peek.test.ts +0 -44
  71. package/src/__tests__/process-management.test.ts +0 -1129
  72. package/src/__tests__/server.test.ts +0 -1020
  73. package/src/__tests__/setup.ts +0 -13
  74. package/src/__tests__/utils/claude-mock.ts +0 -87
  75. package/src/__tests__/utils/mcp-client.ts +0 -159
  76. package/src/__tests__/utils/opencode-mock.ts +0 -108
  77. package/src/__tests__/utils/persistent-mock.ts +0 -33
  78. package/src/__tests__/utils/test-helpers.ts +0 -13
  79. package/src/__tests__/validation.test.ts +0 -369
  80. package/src/__tests__/version-print.test.ts +0 -81
  81. package/src/__tests__/wait.test.ts +0 -302
  82. package/src/app/cli.ts +0 -424
  83. package/src/app/mcp.ts +0 -466
  84. package/src/bin/ai-cli-mcp.ts +0 -7
  85. package/src/bin/ai-cli.ts +0 -11
  86. package/src/cli-builder.ts +0 -274
  87. package/src/cli-parse.ts +0 -105
  88. package/src/cli-process-service.ts +0 -709
  89. package/src/cli-utils.ts +0 -258
  90. package/src/cli.ts +0 -124
  91. package/src/model-catalog.ts +0 -87
  92. package/src/parsers.ts +0 -965
  93. package/src/peek.ts +0 -95
  94. package/src/process-result.ts +0 -88
  95. package/src/process-service.ts +0 -368
  96. package/src/server.ts +0 -10
  97. package/tsconfig.json +0 -16
  98. package/vitest.config.e2e.ts +0 -27
  99. package/vitest.config.ts +0 -22
  100. 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
- });