ai-cli-mcp 2.14.1 → 2.16.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.
Files changed (60) hide show
  1. package/.github/dependabot.yml +28 -0
  2. package/.github/workflows/ci.yml +4 -1
  3. package/.github/workflows/dependency-review.yml +22 -0
  4. package/CHANGELOG.md +14 -0
  5. package/README.ja.md +83 -6
  6. package/README.md +83 -7
  7. package/dist/__tests__/app-cli.test.js +80 -5
  8. package/dist/__tests__/cli-bin-smoke.test.js +43 -0
  9. package/dist/__tests__/cli-builder.test.js +93 -15
  10. package/dist/__tests__/cli-process-service.test.js +162 -0
  11. package/dist/__tests__/cli-utils.test.js +31 -0
  12. package/dist/__tests__/e2e.test.js +79 -52
  13. package/dist/__tests__/mcp-contract.test.js +162 -0
  14. package/dist/__tests__/parsers.test.js +224 -1
  15. package/dist/__tests__/peek.test.js +35 -0
  16. package/dist/__tests__/process-management.test.js +160 -1
  17. package/dist/__tests__/server.test.js +39 -9
  18. package/dist/__tests__/utils/opencode-mock.js +91 -0
  19. package/dist/__tests__/validation.test.js +40 -2
  20. package/dist/app/cli.js +47 -5
  21. package/dist/app/mcp.js +53 -4
  22. package/dist/cli-builder.js +67 -28
  23. package/dist/cli-parse.js +11 -5
  24. package/dist/cli-process-service.js +241 -20
  25. package/dist/cli-utils.js +14 -23
  26. package/dist/cli.js +6 -4
  27. package/dist/model-catalog.js +13 -1
  28. package/dist/parsers.js +242 -28
  29. package/dist/peek.js +56 -0
  30. package/dist/process-result.js +9 -2
  31. package/dist/process-service.js +103 -17
  32. package/dist/server.js +1 -2
  33. package/package.json +9 -6
  34. package/src/__tests__/app-cli.test.ts +95 -4
  35. package/src/__tests__/cli-bin-smoke.test.ts +62 -1
  36. package/src/__tests__/cli-builder.test.ts +111 -15
  37. package/src/__tests__/cli-process-service.test.ts +180 -0
  38. package/src/__tests__/cli-utils.test.ts +34 -0
  39. package/src/__tests__/e2e.test.ts +87 -55
  40. package/src/__tests__/mcp-contract.test.ts +188 -0
  41. package/src/__tests__/parsers.test.ts +260 -1
  42. package/src/__tests__/peek.test.ts +43 -0
  43. package/src/__tests__/process-management.test.ts +185 -1
  44. package/src/__tests__/server.test.ts +49 -13
  45. package/src/__tests__/utils/opencode-mock.ts +108 -0
  46. package/src/__tests__/validation.test.ts +48 -2
  47. package/src/app/cli.ts +52 -4
  48. package/src/app/mcp.ts +54 -4
  49. package/src/cli-builder.ts +91 -32
  50. package/src/cli-parse.ts +11 -5
  51. package/src/cli-process-service.ts +304 -17
  52. package/src/cli-utils.ts +37 -33
  53. package/src/cli.ts +6 -4
  54. package/src/model-catalog.ts +24 -1
  55. package/src/parsers.ts +299 -33
  56. package/src/peek.ts +88 -0
  57. package/src/process-result.ts +11 -2
  58. package/src/process-service.ts +134 -15
  59. package/src/server.ts +2 -2
  60. package/vitest.config.unit.ts +2 -3
@@ -3,6 +3,7 @@ import {
3
3
  CLI_HELP_TEXT,
4
4
  DOCTOR_HELP_TEXT,
5
5
  MODELS_HELP_TEXT,
6
+ PEEK_HELP_TEXT,
6
7
  RESULT_HELP_TEXT,
7
8
  RUN_HELP_TEXT,
8
9
  WAIT_HELP_TEXT,
@@ -199,6 +200,64 @@ describe('ai-cli app', () => {
199
200
  expect(waitForProcesses).not.toHaveBeenCalled();
200
201
  });
201
202
 
203
+ it('dispatches peek with deduped pid arguments and time', async () => {
204
+ const stdout = vi.fn();
205
+ const stderr = vi.fn();
206
+ const peekProcesses = vi.fn().mockResolvedValue({
207
+ peek_started_at: '2026-04-11T12:34:56.789Z',
208
+ observed_duration_sec: 0.01,
209
+ processes: [],
210
+ });
211
+
212
+ const exitCode = await runCli(
213
+ ['peek', '123', '456', '123', '--time', '5'],
214
+ {
215
+ stdout,
216
+ stderr,
217
+ peekProcesses,
218
+ }
219
+ );
220
+
221
+ expect(exitCode).toBe(0);
222
+ expect(peekProcesses).toHaveBeenCalledWith([123, 456], 5);
223
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"peek_started_at"'));
224
+ expect(stderr).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('defaults peek time and rejects --follow', async () => {
228
+ const stdout = vi.fn();
229
+ const stderr = vi.fn();
230
+ const peekProcesses = vi.fn().mockResolvedValue({
231
+ peek_started_at: '2026-04-11T12:34:56.789Z',
232
+ observed_duration_sec: 0.01,
233
+ processes: [],
234
+ });
235
+
236
+ const defaultExitCode = await runCli(['peek', '123'], { stdout, stderr, peekProcesses });
237
+ expect(defaultExitCode).toBe(0);
238
+ expect(peekProcesses).toHaveBeenCalledWith([123], 10);
239
+
240
+ const followExitCode = await runCli(['peek', '123', '--follow'], { stdout, stderr, peekProcesses });
241
+ expect(followExitCode).toBe(1);
242
+ expect(stderr).toHaveBeenCalledWith('peek does not support --follow in v1\n');
243
+ });
244
+
245
+ it('rejects invalid peek time values', async () => {
246
+ const stdout = vi.fn();
247
+ const stderr = vi.fn();
248
+ const peekProcesses = vi.fn();
249
+
250
+ const exitCode = await runCli(['peek', '123', '--time', '1.5'], {
251
+ stdout,
252
+ stderr,
253
+ peekProcesses,
254
+ });
255
+
256
+ expect(exitCode).toBe(1);
257
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('peek_time_sec must be a positive integer'));
258
+ expect(peekProcesses).not.toHaveBeenCalled();
259
+ });
260
+
202
261
  it('dispatches ps, result, and kill', async () => {
203
262
  const stdout = vi.fn();
204
263
  const stderr = vi.fn();
@@ -236,12 +295,22 @@ describe('ai-cli app', () => {
236
295
  const stderr = vi.fn();
237
296
 
238
297
  const exitCode = await runCli(['models'], { stdout, stderr });
298
+ const payload = JSON.parse(stdout.mock.calls[0][0]);
239
299
 
240
300
  expect(exitCode).toBe(0);
241
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"aliases"'));
242
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"claude-ultra"'));
243
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"gpt-5.4"'));
244
- expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"forge"'));
301
+ expect(payload.aliases).toEqual(expect.any(Array));
302
+ expect(payload.claude).toContain('sonnet');
303
+ expect(payload.codex).toContain('gpt-5.4');
304
+ expect(payload.forge).toEqual(['forge']);
305
+ expect(payload.opencode).toEqual(['opencode']);
306
+ expect(payload.dynamicModelBackends).toEqual({
307
+ opencode: {
308
+ explicitPrefix: 'oc-',
309
+ explicitPattern: 'oc-<provider/model>',
310
+ discoveryCommand: 'opencode models',
311
+ modelsAreDynamic: true,
312
+ },
313
+ });
245
314
  expect(stderr).not.toHaveBeenCalled();
246
315
  });
247
316
 
@@ -273,6 +342,12 @@ describe('ai-cli app', () => {
273
342
  available: true,
274
343
  lookup: 'path',
275
344
  },
345
+ opencode: {
346
+ configuredCommand: 'opencode',
347
+ resolvedPath: '/tmp/bin/opencode',
348
+ available: true,
349
+ lookup: 'path',
350
+ },
276
351
  });
277
352
 
278
353
  const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
@@ -307,6 +382,8 @@ describe('ai-cli app', () => {
307
382
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
308
383
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
309
384
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
385
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('opencode'));
386
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('oc-openai/gpt-5.4'));
310
387
  expect(stderr).not.toHaveBeenCalled();
311
388
  });
312
389
 
@@ -336,6 +413,18 @@ describe('ai-cli app', () => {
336
413
  expect(stderr).not.toHaveBeenCalled();
337
414
  });
338
415
 
416
+ it('prints detailed help for peek --help', async () => {
417
+ const stdout = vi.fn();
418
+ const stderr = vi.fn();
419
+
420
+ const exitCode = await runCli(['peek', '--help'], { stdout, stderr });
421
+
422
+ expect(exitCode).toBe(0);
423
+ expect(stdout).toHaveBeenCalledWith(PEEK_HELP_TEXT);
424
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('No --follow mode'));
425
+ expect(stderr).not.toHaveBeenCalled();
426
+ });
427
+
339
428
  it('prints detailed help for models --help', async () => {
340
429
  const stdout = vi.fn();
341
430
  const stderr = vi.fn();
@@ -355,6 +444,7 @@ describe('ai-cli app', () => {
355
444
 
356
445
  expect(exitCode).toBe(0);
357
446
  expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
447
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('OpenCode'));
358
448
  expect(stderr).not.toHaveBeenCalled();
359
449
  });
360
450
 
@@ -366,6 +456,7 @@ describe('ai-cli app', () => {
366
456
 
367
457
  expect(exitCode).toBe(0);
368
458
  expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
459
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('OpenCode'));
369
460
  expect(stderr).not.toHaveBeenCalled();
370
461
  });
371
462
 
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { delimiter, join } from 'node:path';
5
5
  import { afterEach, describe, expect, it } from 'vitest';
@@ -24,6 +24,62 @@ afterEach(() => {
24
24
  }
25
25
  });
26
26
 
27
+ describe('cli helper entrypoint smoke', () => {
28
+ it('prints help for cli.run with OpenCode examples', () => {
29
+ const output = execFileSync(
30
+ 'node',
31
+ ['--import', 'tsx', 'src/cli.ts', '--help'],
32
+ {
33
+ cwd: process.cwd(),
34
+ encoding: 'utf8',
35
+ env: process.env,
36
+ }
37
+ );
38
+
39
+ expect(output).toContain('Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]');
40
+ expect(output).toContain('opencode');
41
+ expect(output).toContain('oc-openai/gpt-5.4');
42
+ expect(output).toContain('OpenCode');
43
+ expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
44
+ });
45
+
46
+ it('prints help for cli.run.parse with OpenCode agent support', () => {
47
+ const output = execFileSync(
48
+ 'node',
49
+ ['--import', 'tsx', 'src/cli-parse.ts', '--help'],
50
+ {
51
+ cwd: process.cwd(),
52
+ encoding: 'utf8',
53
+ env: process.env,
54
+ }
55
+ );
56
+
57
+ expect(output).toContain('Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>');
58
+ expect(output).toContain('Agent type: claude, codex, gemini, forge, or opencode');
59
+ expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
60
+ });
61
+
62
+ it('parses OpenCode NDJSON through cli.run.parse', () => {
63
+ const output = execFileSync(
64
+ 'node',
65
+ ['--import', 'tsx', 'src/cli-parse.ts', '--agent', 'opencode'],
66
+ {
67
+ cwd: process.cwd(),
68
+ encoding: 'utf8',
69
+ env: process.env,
70
+ input: '{"type":"step_start","sessionID":"ses_cli_parse"}\n{"type":"text","sessionID":"ses_cli_parse","part":{"type":"text","text":"Hello from cli.parse"}}\n{"type":"step_finish","sessionID":"ses_cli_parse","part":{"type":"step-finish","tokens":{"total":9},"cost":1}}\n',
71
+ }
72
+ );
73
+
74
+ expect(JSON.parse(output)).toEqual({
75
+ message: 'Hello from cli.parse',
76
+ session_id: 'ses_cli_parse',
77
+ tokens: { total: 9 },
78
+ cost: 1,
79
+ });
80
+ });
81
+ });
82
+
27
83
  describe('ai-cli entrypoint smoke', () => {
28
84
  it('prints doctor output for the ai-cli entrypoint', () => {
29
85
  const fakeBinDir = makeTempDir('ai-cli-bin-');
@@ -31,6 +87,7 @@ describe('ai-cli entrypoint smoke', () => {
31
87
  writeExecutable(fakeBinDir, 'codex');
32
88
  writeExecutable(fakeBinDir, 'gemini');
33
89
  writeExecutable(fakeBinDir, 'forge');
90
+ writeExecutable(fakeBinDir, 'opencode');
34
91
 
35
92
  const output = execFileSync(
36
93
  'node',
@@ -45,6 +102,7 @@ describe('ai-cli entrypoint smoke', () => {
45
102
  CODEX_CLI_NAME: 'codex',
46
103
  GEMINI_CLI_NAME: 'gemini',
47
104
  FORGE_CLI_NAME: 'forge',
105
+ OPENCODE_CLI_NAME: 'opencode',
48
106
  },
49
107
  }
50
108
  );
@@ -53,6 +111,7 @@ describe('ai-cli entrypoint smoke', () => {
53
111
  expect(output).toContain('"codex"');
54
112
  expect(output).toContain('"gemini"');
55
113
  expect(output).toContain('"forge"');
114
+ expect(output).toContain('"opencode"');
56
115
  expect(output).toContain('"available": true');
57
116
  });
58
117
 
@@ -71,5 +130,7 @@ describe('ai-cli entrypoint smoke', () => {
71
130
  expect(output).toContain('--model <model>');
72
131
  expect(output).toContain('claude-ultra');
73
132
  expect(output).toContain('forge');
133
+ expect(output).toContain('opencode');
134
+ expect(output).toContain('oc-openai/gpt-5.4');
74
135
  });
75
136
  });
@@ -23,6 +23,7 @@ const DEFAULT_CLI_PATHS = {
23
23
  codex: '/usr/bin/codex',
24
24
  gemini: '/usr/bin/gemini',
25
25
  forge: '/usr/bin/forge',
26
+ opencode: '/usr/bin/opencode',
26
27
  };
27
28
 
28
29
  describe('cli-builder', () => {
@@ -104,6 +105,15 @@ describe('cli-builder', () => {
104
105
  'reasoning_effort is not supported for forge.'
105
106
  );
106
107
  });
108
+
109
+ it('should reject reasoning_effort for opencode explicitly', () => {
110
+ expect(() => getReasoningEffort('opencode', 'high')).toThrow(
111
+ 'reasoning_effort is not supported for opencode.'
112
+ );
113
+ expect(() => getReasoningEffort('oc-openai/gpt-5.4', 'high')).toThrow(
114
+ 'reasoning_effort is not supported for opencode.'
115
+ );
116
+ });
107
117
  });
108
118
 
109
119
  describe('buildCliCommand', () => {
@@ -378,7 +388,7 @@ describe('cli-builder', () => {
378
388
  expect(cmd.cliPath).toBe('/usr/bin/gemini');
379
389
  expect(cmd.args).toContain('-y');
380
390
  expect(cmd.args).toContain('--output-format');
381
- expect(cmd.args).toContain('json');
391
+ expect(cmd.args).toContain('stream-json');
382
392
  expect(cmd.args).toContain('--model');
383
393
  expect(cmd.args).toContain('gemini-2.5-pro');
384
394
  });
@@ -409,43 +419,129 @@ describe('cli-builder', () => {
409
419
  });
410
420
  });
411
421
 
412
- describe('forge agent', () => {
413
- it('should build forge command without model flags', () => {
422
+ describe('opencode agent', () => {
423
+ it('should build default opencode command without --model', () => {
414
424
  const cmd = buildCliCommand({
415
425
  prompt: 'test',
416
426
  workFolder: '/tmp',
417
- model: 'forge',
427
+ model: 'opencode',
418
428
  cliPaths: DEFAULT_CLI_PATHS,
419
429
  });
420
430
 
421
- expect(cmd.agent).toBe('forge');
422
- expect(cmd.cliPath).toBe('/usr/bin/forge');
423
- expect(cmd.resolvedModel).toBe('forge');
424
- expect(cmd.args).toEqual(['-C', '/tmp', '-p', 'test']);
431
+ expect(cmd.agent).toBe('opencode');
432
+ expect(cmd.cliPath).toBe('/usr/bin/opencode');
433
+ expect(cmd.cwd).toBe('/tmp');
434
+ expect(cmd.args).toEqual(['run', '--format', 'json', '--dir', '/tmp', 'test']);
435
+ expect(cmd.args).not.toContain('--model');
425
436
  });
426
437
 
427
- it('should map session_id to --conversation-id for forge', () => {
438
+ it('should route valid explicit OpenCode model syntax', () => {
428
439
  const cmd = buildCliCommand({
429
440
  prompt: 'test',
430
441
  workFolder: '/tmp',
431
- model: 'forge',
432
- session_id: 'forge-conv-123',
442
+ model: 'oc-openai/gpt-5.4',
433
443
  cliPaths: DEFAULT_CLI_PATHS,
434
444
  });
435
445
 
436
- expect(cmd.args).toEqual(['-C', '/tmp', '--conversation-id', 'forge-conv-123', '-p', 'test']);
446
+ expect(cmd.agent).toBe('opencode');
447
+ expect(cmd.resolvedModel).toBe('oc-openai/gpt-5.4');
448
+ expect(cmd.args).toEqual([
449
+ 'run',
450
+ '--format',
451
+ 'json',
452
+ '--dir',
453
+ '/tmp',
454
+ '--model',
455
+ 'openai/gpt-5.4',
456
+ 'test',
457
+ ]);
458
+ });
459
+
460
+ it.each([
461
+ 'oc-',
462
+ 'oc-openai',
463
+ 'oc-/gpt-5.4',
464
+ 'oc-openai/',
465
+ ])('should reject invalid explicit OpenCode syntax: %s', (model) => {
466
+ expect(() =>
467
+ buildCliCommand({
468
+ prompt: 'test',
469
+ workFolder: '/tmp',
470
+ model,
471
+ cliPaths: DEFAULT_CLI_PATHS,
472
+ })
473
+ ).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
437
474
  });
438
475
 
439
- it('should reject reasoning_effort for forge in command building', () => {
476
+ it.each([' oc-openai/gpt-5.4', 'oc-openai/gpt-5.4 '])(
477
+ 'should reject explicit OpenCode models with surrounding whitespace: %s',
478
+ (model) => {
479
+ expect(() =>
480
+ buildCliCommand({
481
+ prompt: 'test',
482
+ workFolder: '/tmp',
483
+ model,
484
+ cliPaths: DEFAULT_CLI_PATHS,
485
+ })
486
+ ).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
487
+ }
488
+ );
489
+
490
+ it('should reject reasoning_effort for OpenCode in command building', () => {
440
491
  expect(() =>
441
492
  buildCliCommand({
442
493
  prompt: 'test',
443
494
  workFolder: '/tmp',
444
- model: 'forge',
495
+ model: 'opencode',
445
496
  reasoning_effort: 'high',
446
497
  cliPaths: DEFAULT_CLI_PATHS,
447
498
  })
448
- ).toThrow('reasoning_effort is not supported for forge.');
499
+ ).toThrow('reasoning_effort is not supported for opencode.');
500
+ });
501
+
502
+ it('should build resumed default OpenCode command', () => {
503
+ const cmd = buildCliCommand({
504
+ prompt: 'resume prompt',
505
+ workFolder: '/tmp',
506
+ model: 'opencode',
507
+ session_id: 'ses-123',
508
+ cliPaths: DEFAULT_CLI_PATHS,
509
+ });
510
+
511
+ expect(cmd.args).toEqual([
512
+ 'run',
513
+ '--format',
514
+ 'json',
515
+ '--dir',
516
+ '/tmp',
517
+ '--session',
518
+ 'ses-123',
519
+ 'resume prompt',
520
+ ]);
521
+ expect(cmd.args).not.toContain('--model');
522
+ });
523
+
524
+ it('should build resumed explicit OpenCode command', () => {
525
+ const cmd = buildCliCommand({
526
+ prompt: 'resume prompt',
527
+ workFolder: '/tmp',
528
+ model: 'oc-openai/gpt-5.4',
529
+ session_id: 'ses-456',
530
+ cliPaths: DEFAULT_CLI_PATHS,
531
+ });
532
+
533
+ expect(cmd.args).toEqual([
534
+ 'run',
535
+ '--format',
536
+ 'json',
537
+ '--dir',
538
+ '/tmp',
539
+ '--session',
540
+ 'ses-456',
541
+ '--model',
542
+ 'openai/gpt-5.4',
543
+ 'resume prompt',
544
+ ]);
449
545
  });
450
546
  });
451
547
  });
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
5
  import { CliProcessService } from '../cli-process-service.js';
6
+ import { createOpenCodeMock } from './utils/opencode-mock.js';
6
7
 
7
8
  function createMockCliScript(dir: string, name: string, options: { ignoreSigterm?: boolean } = {}): string {
8
9
  const scriptPath = join(dir, name);
@@ -66,6 +67,7 @@ describe('CliProcessService', () => {
66
67
  codex: scriptPath,
67
68
  gemini: scriptPath,
68
69
  forge: scriptPath,
70
+ opencode: scriptPath,
69
71
  },
70
72
  });
71
73
 
@@ -120,6 +122,74 @@ describe('CliProcessService', () => {
120
122
  expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
121
123
  });
122
124
 
125
+ it('peeks only appended natural-language messages from detached logs', async () => {
126
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
127
+ tempDirs.push(root);
128
+ const scriptPath = join(root, 'mock-claude-peek');
129
+ writeFileSync(
130
+ scriptPath,
131
+ `#!/bin/bash
132
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
133
+ sleep 2
134
+ 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"}}]}}'
135
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
136
+ `
137
+ );
138
+ chmodSync(scriptPath, 0o755);
139
+ const stateDir = join(root, 'state');
140
+ const workFolder = join(root, 'work');
141
+ mkdirSync(workFolder, { recursive: true });
142
+
143
+ const service = new CliProcessService({
144
+ stateDir,
145
+ cliPaths: {
146
+ claude: scriptPath,
147
+ codex: scriptPath,
148
+ gemini: scriptPath,
149
+ forge: scriptPath,
150
+ opencode: scriptPath,
151
+ },
152
+ });
153
+
154
+ const runResult = await service.startProcess({
155
+ prompt: 'hello peek',
156
+ cwd: workFolder,
157
+ });
158
+
159
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
160
+ const stdoutPath = join(processDir, 'stdout.log');
161
+ const startedAt = Date.now();
162
+ while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
163
+ await new Promise((resolve) => setTimeout(resolve, 25));
164
+ }
165
+ expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
166
+
167
+ const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
168
+
169
+ expect(peekResult.processes).toHaveLength(2);
170
+ expect(peekResult.processes[0]).toMatchObject({
171
+ pid: runResult.pid,
172
+ agent: 'claude',
173
+ status: 'completed',
174
+ messages: [
175
+ {
176
+ ts: expect.any(String),
177
+ text: 'new cli message',
178
+ },
179
+ ],
180
+ truncated: false,
181
+ error: null,
182
+ });
183
+ expect(peekResult.processes[1]).toEqual({
184
+ pid: 999999,
185
+ agent: null,
186
+ status: 'not_found',
187
+ messages: [],
188
+ truncated: false,
189
+ error: 'process not found',
190
+ });
191
+ });
192
+
123
193
  it('returns compact results by default and full results when verbose is true', async () => {
124
194
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
125
195
  tempDirs.push(root);
@@ -145,6 +215,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
145
215
  codex: scriptPath,
146
216
  gemini: scriptPath,
147
217
  forge: scriptPath,
218
+ opencode: scriptPath,
148
219
  },
149
220
  });
150
221
 
@@ -255,6 +326,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
255
326
  codex: scriptPath,
256
327
  gemini: scriptPath,
257
328
  forge: scriptPath,
329
+ opencode: scriptPath,
258
330
  },
259
331
  });
260
332
 
@@ -294,6 +366,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
294
366
  codex: '/bin/sh',
295
367
  gemini: '/bin/sh',
296
368
  forge: '/bin/sh',
369
+ opencode: '/bin/sh',
297
370
  },
298
371
  });
299
372
 
@@ -368,6 +441,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
368
441
  codex: '/bin/sh',
369
442
  gemini: '/bin/sh',
370
443
  forge: '/bin/sh',
444
+ opencode: '/bin/sh',
371
445
  },
372
446
  });
373
447
 
@@ -426,6 +500,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
426
500
  codex: '/bin/sh',
427
501
  gemini: '/bin/sh',
428
502
  forge: '/bin/sh',
503
+ opencode: '/bin/sh',
429
504
  },
430
505
  });
431
506
 
@@ -502,6 +577,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
502
577
  codex: '/bin/sh',
503
578
  gemini: '/bin/sh',
504
579
  forge: '/bin/sh',
580
+ opencode: '/bin/sh',
505
581
  },
506
582
  });
507
583
 
@@ -564,6 +640,7 @@ Forge assistant reply
564
640
  codex: '/bin/sh',
565
641
  gemini: '/bin/sh',
566
642
  forge: '/bin/sh',
643
+ opencode: '/bin/sh',
567
644
  },
568
645
  });
569
646
 
@@ -575,4 +652,107 @@ Forge assistant reply
575
652
  session_id: 'forge-conv-1',
576
653
  });
577
654
  });
655
+
656
+ it('parses successful OpenCode detached runs from stdout only', async () => {
657
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
658
+ tempDirs.push(root);
659
+ const stateDir = join(root, 'state');
660
+ const workFolder = join(root, 'opencode-project');
661
+ mkdirSync(workFolder, { recursive: true });
662
+ const argsLogPath = join(root, 'opencode-args.log');
663
+ const { scriptPath } = createOpenCodeMock(root, { argsLogPath });
664
+
665
+ const service = new CliProcessService({
666
+ stateDir,
667
+ cliPaths: {
668
+ claude: '/bin/sh',
669
+ codex: '/bin/sh',
670
+ gemini: '/bin/sh',
671
+ forge: '/bin/sh',
672
+ opencode: scriptPath,
673
+ },
674
+ });
675
+
676
+ const runResult = await service.startProcess({
677
+ prompt: 'hello opencode',
678
+ cwd: workFolder,
679
+ model: 'opencode',
680
+ });
681
+
682
+ const waited = await service.waitForProcesses([runResult.pid], 5);
683
+ expect(waited).toHaveLength(1);
684
+ expect(waited[0]).toMatchObject({
685
+ pid: runResult.pid,
686
+ agent: 'opencode',
687
+ status: 'completed',
688
+ exitCode: 0,
689
+ model: 'opencode',
690
+ session_id: 'ses-opencode-default',
691
+ agentOutput: {
692
+ message: 'Initial: hello opencode',
693
+ session_id: 'ses-opencode-default',
694
+ tokens: { total: 11833 },
695
+ cost: 0,
696
+ },
697
+ });
698
+ expect(waited[0]).not.toHaveProperty('stdout');
699
+ expect(waited[0]).not.toHaveProperty('stderr');
700
+ expect(readFileSync(argsLogPath, 'utf8')).toContain(`--dir ${workFolder}`);
701
+ });
702
+
703
+ it('preserves raw stdout and stderr for failed detached OpenCode runs', async () => {
704
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
705
+ tempDirs.push(root);
706
+ const stateDir = join(root, 'state');
707
+ const workFolder = join(root, 'opencode-fail-project');
708
+ mkdirSync(workFolder, { recursive: true });
709
+ const { scriptPath } = createOpenCodeMock(root);
710
+
711
+ const service = new CliProcessService({
712
+ stateDir,
713
+ cliPaths: {
714
+ claude: '/bin/sh',
715
+ codex: '/bin/sh',
716
+ gemini: '/bin/sh',
717
+ forge: '/bin/sh',
718
+ opencode: scriptPath,
719
+ },
720
+ });
721
+
722
+ const runResult = await service.startProcess({
723
+ prompt: 'please fail',
724
+ cwd: workFolder,
725
+ model: 'oc-openai/gpt-5.4',
726
+ });
727
+
728
+ const [compactResult] = await service.waitForProcesses([runResult.pid], 5);
729
+ expect(compactResult).toMatchObject({
730
+ pid: runResult.pid,
731
+ agent: 'opencode',
732
+ status: 'failed',
733
+ exitCode: 7,
734
+ model: 'oc-openai/gpt-5.4',
735
+ session_id: 'ses-opencode-default',
736
+ stdout: expect.stringContaining('Partial failure output'),
737
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
738
+ });
739
+ expect(compactResult).not.toHaveProperty('agentOutput');
740
+
741
+ const verboseResult = await service.getProcessResult(runResult.pid, true);
742
+ expect(verboseResult).toMatchObject({
743
+ pid: runResult.pid,
744
+ agent: 'opencode',
745
+ status: 'failed',
746
+ exitCode: 7,
747
+ session_id: 'ses-opencode-default',
748
+ stdout: expect.stringContaining('Partial failure output'),
749
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
750
+ agentOutput: {
751
+ message: 'Partial failure output',
752
+ session_id: 'ses-opencode-default',
753
+ tokens: { total: 42 },
754
+ cost: 0,
755
+ },
756
+ });
757
+ });
578
758
  });