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
@@ -0,0 +1,108 @@
1
+ import { chmodSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export interface OpenCodeMockOptions {
5
+ argsLogPath?: string;
6
+ defaultSessionId?: string;
7
+ }
8
+
9
+ export interface OpenCodeMockResult {
10
+ scriptPath: string;
11
+ argsLogPath?: string;
12
+ }
13
+
14
+ export function createOpenCodeMock(dir: string, options: OpenCodeMockOptions = {}): OpenCodeMockResult {
15
+ const scriptPath = join(dir, 'mock-opencode');
16
+ const defaultSessionId = options.defaultSessionId || 'ses-opencode-default';
17
+ const argsLogPath = options.argsLogPath;
18
+ const argsLogSection = argsLogPath
19
+ ? `printf '%s\n' "$*" >> "${argsLogPath}"\n`
20
+ : '';
21
+
22
+ writeFileSync(
23
+ scriptPath,
24
+ `#!/bin/bash
25
+ set -euo pipefail
26
+
27
+ prompt=""
28
+ session_id=""
29
+ session_provided=0
30
+ model=""
31
+ work_dir=""
32
+
33
+ ${argsLogSection}if [[ "\${1:-}" == "run" ]]; then
34
+ shift
35
+ fi
36
+
37
+ while [[ $# -gt 0 ]]; do
38
+ case "$1" in
39
+ --format)
40
+ shift 2
41
+ ;;
42
+ --dir)
43
+ work_dir="$2"
44
+ shift 2
45
+ ;;
46
+ --session)
47
+ session_id="$2"
48
+ session_provided=1
49
+ shift 2
50
+ ;;
51
+ --model)
52
+ model="$2"
53
+ shift 2
54
+ ;;
55
+ *)
56
+ prompt="$1"
57
+ shift
58
+ ;;
59
+ esac
60
+ done
61
+
62
+ if [[ -z "$session_id" ]]; then
63
+ session_id="${defaultSessionId}"
64
+ fi
65
+
66
+ if [[ "$prompt" == *"sleep"* ]]; then
67
+ sleep 5
68
+ fi
69
+
70
+ if [[ "$prompt" == *"fail"* ]]; then
71
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
72
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Partial failure output"}}\n' "$session_id"
73
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":42},"cost":0}}\n' "$session_id"
74
+ printf 'OpenCode failed for %s in %s\n' "$model" "$work_dir" >&2
75
+ exit 7
76
+ fi
77
+
78
+ if [[ "$prompt" == *"multi-step"* ]]; then
79
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
80
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"First step"}}\n' "$session_id"
81
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11},"cost":0}}\n' "$session_id"
82
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
83
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Second step"}}\n' "$session_id"
84
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":22},"cost":1}}\n' "$session_id"
85
+ exit 0
86
+ fi
87
+
88
+ message_prefix="Initial"
89
+ if [[ $session_provided -eq 1 ]]; then
90
+ message_prefix="Resumed"
91
+ fi
92
+ if [[ -n "$model" ]]; then
93
+ message_prefix="Model $model"
94
+ if [[ $session_provided -eq 1 ]]; then
95
+ message_prefix="Resumed model $model"
96
+ fi
97
+ fi
98
+
99
+ printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
100
+ printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"%s: %s"}}\n' "$session_id" "$message_prefix" "$prompt"
101
+ printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}\n' "$session_id"
102
+ `,
103
+ 'utf8',
104
+ );
105
+ chmodSync(scriptPath, 0o755);
106
+
107
+ return { scriptPath, argsLogPath };
108
+ }
@@ -69,7 +69,6 @@ describe('Argument Validation Tests', () => {
69
69
  beforeEach(() => {
70
70
  vi.clearAllMocks();
71
71
  vi.resetModules();
72
- vi.unmock('../server.js');
73
72
  consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
74
73
  // Set up process.env
75
74
  process.env = { ...process.env };
@@ -80,6 +79,7 @@ describe('Argument Validation Tests', () => {
80
79
  mockHomedir.mockReturnValue('/home/user');
81
80
  mockExistsSync.mockReturnValue(true);
82
81
  setupServerMock();
82
+ vi.doUnmock('../server.js');
83
83
  const module = await import('../server.js');
84
84
  // @ts-ignore
85
85
  const { ClaudeCodeServer } = module;
@@ -114,6 +114,7 @@ describe('Argument Validation Tests', () => {
114
114
  mockHomedir.mockReturnValue('/home/user');
115
115
  mockExistsSync.mockReturnValue(true);
116
116
  setupServerMock();
117
+ vi.doUnmock('../server.js');
117
118
  const module = await import('../server.js');
118
119
  // @ts-ignore
119
120
  const { ClaudeCodeServer } = module;
@@ -216,7 +217,7 @@ describe('Argument Validation Tests', () => {
216
217
 
217
218
  const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
218
219
 
219
- vi.mocked(Server).mockImplementation(() => {
220
+ vi.mocked(Server).mockImplementation(function(this: any) {
220
221
  mockServerInstance = {
221
222
  setRequestHandler: vi.fn((schema: any, handler: Function) => {
222
223
  handlers.set(schema.name, handler);
@@ -228,6 +229,7 @@ describe('Argument Validation Tests', () => {
228
229
  return mockServerInstance as any;
229
230
  });
230
231
 
232
+ vi.doUnmock('../server.js');
231
233
  const module = await import('../server.js');
232
234
  // @ts-ignore
233
235
  const { ClaudeCodeServer } = module;
@@ -319,5 +321,49 @@ describe('Argument Validation Tests', () => {
319
321
  })
320
322
  ).rejects.toThrow(/reasoning_effort/i);
321
323
  });
324
+
325
+ it.each([
326
+ 'oc-',
327
+ 'oc-openai',
328
+ 'oc-/gpt-5.4',
329
+ 'oc-openai/',
330
+ ' oc-openai/gpt-5.4',
331
+ 'oc-openai/gpt-5.4 ',
332
+ ])('should reject malformed OpenCode model syntax at runtime: %s', async (model) => {
333
+ await setupServer();
334
+ const handler = handlers.get('callTool')!;
335
+
336
+ await expect(
337
+ handler({
338
+ params: {
339
+ name: 'run',
340
+ arguments: {
341
+ prompt: 'test',
342
+ workFolder: '/tmp',
343
+ model,
344
+ }
345
+ }
346
+ })
347
+ ).rejects.toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
348
+ });
349
+
350
+ it('should reject reasoning_effort for OpenCode runtime requests', async () => {
351
+ await setupServer();
352
+ const handler = handlers.get('callTool')!;
353
+
354
+ await expect(
355
+ handler({
356
+ params: {
357
+ name: 'run',
358
+ arguments: {
359
+ prompt: 'test',
360
+ workFolder: '/tmp',
361
+ model: 'opencode',
362
+ reasoning_effort: 'high',
363
+ }
364
+ }
365
+ })
366
+ ).rejects.toThrow('reasoning_effort is not supported for opencode.');
367
+ });
322
368
  });
323
369
  });
package/src/app/cli.ts CHANGED
@@ -2,12 +2,14 @@ import { runMcpServer } from './mcp.js';
2
2
  import { CliProcessService } from '../cli-process-service.js';
3
3
  import { getCliDoctorStatus } from '../cli-utils.js';
4
4
  import { getModelsPayload } from '../model-catalog.js';
5
+ import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
5
6
 
6
7
  export const CLI_HELP_TEXT = `Usage: ai-cli <command> [options]
7
8
 
8
9
  Commands:
9
10
  run Start an AI CLI process in the background
10
11
  wait Wait for one or more pids
12
+ peek Observe new natural-language agent messages for a short window
11
13
  ps List tracked processes
12
14
  result Get the current result for a pid
13
15
  kill Terminate a tracked pid
@@ -26,9 +28,9 @@ Options:
26
28
  --cwd <path> Working directory
27
29
  --prompt <text> Prompt text
28
30
  --prompt-file <path> Path to a prompt file
29
- --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra, forge)
30
- --session-id <id> Resume a previous session
31
- --reasoning-effort <level> Reasoning level for Claude/Codex only
31
+ --model <model> Model name or alias (e.g. sonnet, claude-ultra, gpt-5.2-codex, codex-ultra, gemini-2.5-pro, gemini-ultra, forge, opencode, oc-openai/gpt-5.4)
32
+ --session-id <id> Resume a previous session, including OpenCode in-place resumes
33
+ --reasoning-effort <level> Reasoning level for Claude/Codex only; unsupported for Gemini, Forge, and OpenCode
32
34
  --help, -h Show this help message
33
35
 
34
36
  Compatibility aliases:
@@ -58,6 +60,17 @@ Options:
58
60
  --help, -h Show this help message
59
61
  `;
60
62
 
63
+ export const PEEK_HELP_TEXT = `Usage: ai-cli peek <pid...> [options]
64
+
65
+ Observe new natural-language agent messages for a short one-shot window.
66
+ In v1, message extraction is supported for Codex, Claude, OpenCode, and Gemini; Forge returns status with messages: [].
67
+ This is not a history API, gapless streaming, or stdout/stderr tailing. No --follow mode is available in v1.
68
+
69
+ Options:
70
+ --time <seconds> Observation window in seconds. Defaults to 10, maximum 60
71
+ --help, -h Show this help message
72
+ `;
73
+
61
74
  export const KILL_HELP_TEXT = `Usage: ai-cli kill <pid>
62
75
 
63
76
  Terminate a tracked process.
@@ -92,7 +105,7 @@ Options:
92
105
 
93
106
  export const DOCTOR_HELP_TEXT = `Usage: ai-cli doctor
94
107
 
95
- Check whether supported AI CLI binaries are available.
108
+ Check whether supported AI CLI binaries are available, including OpenCode.
96
109
 
97
110
  Options:
98
111
  --help, -h Show this help message
@@ -118,6 +131,7 @@ interface CliDeps {
118
131
  listProcesses: () => Promise<any>;
119
132
  getProcessResult: (pid: number, verbose: boolean) => Promise<any>;
120
133
  waitForProcesses: (pids: number[], timeoutSeconds?: number, verbose?: boolean) => Promise<any>;
134
+ peekProcesses: (pids: number[], peekTimeSec?: number) => Promise<any>;
121
135
  killProcess: (pid: number) => Promise<any>;
122
136
  cleanupProcesses: () => Promise<any>;
123
137
  getDoctorStatus: () => any;
@@ -140,6 +154,7 @@ const defaultDeps: CliDeps = {
140
154
  listProcesses: () => getCliProcessService().listProcesses(),
141
155
  getProcessResult: (pid, verbose) => getCliProcessService().getProcessResult(pid, verbose),
142
156
  waitForProcesses: (pids, timeoutSeconds, verbose) => getCliProcessService().waitForProcesses(pids, timeoutSeconds, verbose),
157
+ peekProcesses: (pids, peekTimeSec) => getCliProcessService().peekProcesses(pids, peekTimeSec),
143
158
  killProcess: (pid) => getCliProcessService().killProcess(pid),
144
159
  cleanupProcesses: () => getCliProcessService().cleanupProcesses(),
145
160
  getDoctorStatus: () => getCliDoctorStatus(),
@@ -204,6 +219,10 @@ function hasHelpFlag(flags: Record<string, string>): boolean {
204
219
  return 'help' in flags || 'h' in flags;
205
220
  }
206
221
 
222
+ function parsePeekCliPids(values: string[]): number[] {
223
+ return validatePeekPids(values.map((value) => Number(value)));
224
+ }
225
+
207
226
  export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promise<number> {
208
227
  const {
209
228
  stdout,
@@ -213,6 +232,7 @@ export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promi
213
232
  listProcesses,
214
233
  getProcessResult,
215
234
  waitForProcesses,
235
+ peekProcesses,
216
236
  killProcess,
217
237
  cleanupProcesses,
218
238
  getDoctorStatus,
@@ -323,6 +343,34 @@ export async function runCli(argv: string[], deps: Partial<CliDeps> = {}): Promi
323
343
  return 0;
324
344
  }
325
345
 
346
+ if (command === 'peek') {
347
+ const { positionals, flags } = parseArgs(argv.slice(1));
348
+ if (hasHelpFlag(flags)) {
349
+ stdout(PEEK_HELP_TEXT);
350
+ return 0;
351
+ }
352
+ if ('follow' in flags) {
353
+ stderr('peek does not support --follow in v1\n');
354
+ stdout(CLI_HELP_TEXT);
355
+ return 1;
356
+ }
357
+
358
+ let pids: number[];
359
+ let peekTimeSec: number;
360
+ try {
361
+ pids = parsePeekCliPids(positionals);
362
+ const timeRaw = getFirstFlag(flags, ['time']);
363
+ peekTimeSec = validatePeekTimeSec(timeRaw === undefined ? undefined : Number(timeRaw));
364
+ } catch (error: any) {
365
+ stderr(`${error.message}\n`);
366
+ stdout(CLI_HELP_TEXT);
367
+ return 1;
368
+ }
369
+
370
+ writeJson(stdout, await peekProcesses(pids, peekTimeSec));
371
+ return 0;
372
+ }
373
+
326
374
  if (command === 'kill') {
327
375
  const { positionals, flags } = parseArgs(argv.slice(1));
328
376
  if (hasHelpFlag(flags)) {
package/src/app/mcp.ts CHANGED
@@ -8,8 +8,9 @@ import {
8
8
  type ServerResult,
9
9
  } from '@modelcontextprotocol/sdk/types.js';
10
10
  import { spawn } from 'node:child_process';
11
- import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from '../cli-utils.js';
11
+ import { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from '../cli-utils.js';
12
12
  import { getModelParameterDescription, getSupportedModelsDescription } from '../model-catalog.js';
13
+ import { validatePeekPids, validatePeekTimeSec } from '../peek.js';
13
14
  import { ProcessService } from '../process-service.js';
14
15
 
15
16
  // Server version - update this when releasing new versions
@@ -73,6 +74,7 @@ export class ClaudeCodeServer {
73
74
  private codexCliPath: string;
74
75
  private geminiCliPath: string;
75
76
  private forgeCliPath: string;
77
+ private opencodeCliPath: string;
76
78
  private processService: ProcessService;
77
79
  private sigintHandler?: () => Promise<void>;
78
80
  private packageVersion: string;
@@ -82,10 +84,12 @@ export class ClaudeCodeServer {
82
84
  this.codexCliPath = findCodexCli();
83
85
  this.geminiCliPath = findGeminiCli();
84
86
  this.forgeCliPath = findForgeCli();
87
+ this.opencodeCliPath = findOpencodeCli();
85
88
  console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
86
89
  console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
87
90
  console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
88
91
  console.error(`[Setup] Using Forge CLI command/path: ${this.forgeCliPath}`);
92
+ console.error(`[Setup] Using OpenCode CLI command/path: ${this.opencodeCliPath}`);
89
93
  this.packageVersion = SERVER_VERSION;
90
94
  this.processService = new ProcessService({
91
95
  cliPaths: {
@@ -93,6 +97,7 @@ export class ClaudeCodeServer {
93
97
  codex: this.codexCliPath,
94
98
  gemini: this.geminiCliPath,
95
99
  forge: this.forgeCliPath,
100
+ opencode: this.opencodeCliPath,
96
101
  },
97
102
  });
98
103
 
@@ -123,7 +128,7 @@ export class ClaudeCodeServer {
123
128
  tools: [
124
129
  {
125
130
  name: 'run',
126
- description: `AI Agent Runner: Starts a Claude, Codex, Gemini, or Forge CLI process in the background and returns a PID immediately. Use list_processes and get_result to monitor progress.
131
+ description: `AI Agent Runner: Starts a Claude, Codex, Gemini, Forge, or OpenCode CLI process in the background and returns a PID immediately. Use list_processes and get_result to monitor progress.
127
132
 
128
133
  • File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
129
134
  • Code: Generate / analyse / refactor / fix
@@ -167,11 +172,11 @@ ${getSupportedModelsDescription()}
167
172
  },
168
173
  reasoning_effort: {
169
174
  type: 'string',
170
- description: 'Reasoning control for Claude and Codex. Claude uses --effort with "low", "medium", "high". Codex uses model_reasoning_effort with "low", "medium", "high", "xhigh". Forge does not support reasoning_effort in this integration.',
175
+ description: 'Reasoning control for Claude and Codex. Claude uses --effort with "low", "medium", "high". Codex uses model_reasoning_effort with "low", "medium", "high", "xhigh". Gemini, Forge, and OpenCode do not support reasoning_effort in this integration.',
171
176
  },
172
177
  session_id: {
173
178
  type: 'string',
174
- description: 'Optional session ID to resume a previous session. Supported for: haiku, sonnet, opus, gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, gemini-3-pro-preview, gemini-3-flash-preview, forge.',
179
+ description: 'Optional session ID to resume a previous session. Supported for Claude, Codex, Gemini, Forge, and OpenCode. OpenCode resumes in-place via --session and may also be combined with explicit oc-<provider/model> selection.',
175
180
  },
176
181
  },
177
182
  required: ['workFolder'],
@@ -226,6 +231,25 @@ ${getSupportedModelsDescription()}
226
231
  required: ['pids'],
227
232
  },
228
233
  },
234
+ {
235
+ name: 'peek',
236
+ description: 'One-shot short observation window for running child agents. Returns only natural-language agent messages observed during this call; not a history API, not gapless streaming, and not stdout/stderr tailing. In v1, message extraction is supported for Codex, Claude, OpenCode, and Gemini; Forge returns status with messages: [].',
237
+ inputSchema: {
238
+ type: 'object',
239
+ properties: {
240
+ pids: {
241
+ type: 'array',
242
+ items: { type: 'number' },
243
+ description: 'Process IDs returned by run. Duplicates are deduplicated server-side, preserving first occurrence order. Unknown PIDs are returned per process as not_found.',
244
+ },
245
+ peek_time_sec: {
246
+ type: 'number',
247
+ description: 'Optional positive integer observation window in seconds. Defaults to 10; maximum is 60.',
248
+ },
249
+ },
250
+ required: ['pids'],
251
+ },
252
+ },
229
253
  {
230
254
  name: 'kill_process',
231
255
  description: 'Terminate a running AI agent process by PID.',
@@ -266,6 +290,8 @@ ${getSupportedModelsDescription()}
266
290
  return this.handleGetResult(toolArguments);
267
291
  case 'wait':
268
292
  return this.handleWait(toolArguments);
293
+ case 'peek':
294
+ return this.handlePeek(toolArguments);
269
295
  case 'kill_process':
270
296
  return this.handleKillProcess(toolArguments);
271
297
  case 'cleanup_processes':
@@ -355,6 +381,30 @@ ${getSupportedModelsDescription()}
355
381
  }
356
382
  }
357
383
 
384
+ private async handlePeek(toolArguments: any): Promise<ServerResult> {
385
+ let pids: number[];
386
+ let peekTimeSec: number;
387
+
388
+ try {
389
+ pids = validatePeekPids(toolArguments.pids);
390
+ peekTimeSec = validatePeekTimeSec(toolArguments.peek_time_sec);
391
+ } catch (error: any) {
392
+ throw new McpError(ErrorCode.InvalidParams, error.message);
393
+ }
394
+
395
+ try {
396
+ const response = await this.processService.peekProcesses(pids, peekTimeSec);
397
+ return {
398
+ content: [{
399
+ type: 'text',
400
+ text: JSON.stringify(response, null, 2)
401
+ }]
402
+ };
403
+ } catch (error: any) {
404
+ throw new McpError(ErrorCode.InternalError, `Failed to peek processes: ${error.message}`);
405
+ }
406
+ }
407
+
358
408
  private async handleKillProcess(toolArguments: any): Promise<ServerResult> {
359
409
  if (!toolArguments.pid || typeof toolArguments.pid !== 'number') {
360
410
  throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pid');
@@ -1,11 +1,21 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { resolve as pathResolve, isAbsolute } from 'node:path';
3
+ import type { CliPaths } from './cli-utils.js';
3
4
  import { MODEL_ALIASES } from './model-catalog.js';
4
5
 
5
6
  export const ALLOWED_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
6
7
  const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
8
+ const OPENCODE_MODEL_ERROR = 'Invalid OpenCode model. Expected exact syntax oc-<provider/model>.';
7
9
 
8
- function getAgentForModel(model: string): 'codex' | 'claude' | 'gemini' | 'forge' {
10
+ type Agent = 'codex' | 'claude' | 'gemini' | 'forge' | 'opencode';
11
+
12
+ interface ModelSelection {
13
+ agent: Agent;
14
+ resolvedModel: string;
15
+ openCodeModel: string | null;
16
+ }
17
+
18
+ function getStandardAgentForModel(model: string): Exclude<Agent, 'opencode'> {
9
19
  if (model === 'forge') {
10
20
  return 'forge';
11
21
  }
@@ -18,20 +28,63 @@ function getAgentForModel(model: string): 'codex' | 'claude' | 'gemini' | 'forge
18
28
  return 'claude';
19
29
  }
20
30
 
21
- /**
22
- * Resolves model aliases to their full model names
23
- * @param model - The model name or alias to resolve
24
- * @returns The full model name, or the original value if no alias exists
25
- */
31
+ function isPotentialOpenCodeExplicitModel(rawModel: string): boolean {
32
+ return rawModel.startsWith('oc-') || rawModel.trim().startsWith('oc-');
33
+ }
34
+
35
+ function extractOpenCodeModel(rawModel: string): string {
36
+ if (rawModel !== rawModel.trim()) {
37
+ throw new Error(OPENCODE_MODEL_ERROR);
38
+ }
39
+
40
+ if (!rawModel.startsWith('oc-')) {
41
+ throw new Error(OPENCODE_MODEL_ERROR);
42
+ }
43
+
44
+ const remainder = rawModel.slice(3);
45
+ const slashIndex = remainder.indexOf('/');
46
+ if (slashIndex === -1) {
47
+ throw new Error(OPENCODE_MODEL_ERROR);
48
+ }
49
+
50
+ const provider = remainder.slice(0, slashIndex);
51
+ const model = remainder.slice(slashIndex + 1);
52
+ if (!provider || !model) {
53
+ throw new Error(OPENCODE_MODEL_ERROR);
54
+ }
55
+
56
+ return remainder;
57
+ }
58
+
59
+ function resolveModelSelection(rawModel: string): ModelSelection {
60
+ if (rawModel === 'opencode') {
61
+ return {
62
+ agent: 'opencode',
63
+ resolvedModel: rawModel,
64
+ openCodeModel: null,
65
+ };
66
+ }
67
+
68
+ if (isPotentialOpenCodeExplicitModel(rawModel)) {
69
+ return {
70
+ agent: 'opencode',
71
+ resolvedModel: rawModel,
72
+ openCodeModel: extractOpenCodeModel(rawModel),
73
+ };
74
+ }
75
+
76
+ const resolvedModel = resolveModelAlias(rawModel);
77
+ return {
78
+ agent: getStandardAgentForModel(resolvedModel),
79
+ resolvedModel,
80
+ openCodeModel: null,
81
+ };
82
+ }
83
+
26
84
  export function resolveModelAlias(model: string): string {
27
85
  return MODEL_ALIASES[model] || model;
28
86
  }
29
87
 
30
- /**
31
- * Validates and normalizes reasoning effort parameter.
32
- * @returns normalized reasoning effort string, or '' if not applicable
33
- * @throws Error for invalid values (plain Error, not MCP-specific)
34
- */
35
88
  export function getReasoningEffort(model: string, rawValue: unknown): string {
36
89
  if (typeof rawValue !== 'string') {
37
90
  return '';
@@ -40,13 +93,18 @@ export function getReasoningEffort(model: string, rawValue: unknown): string {
40
93
  if (!trimmed) {
41
94
  return '';
42
95
  }
96
+
97
+ if (model === 'opencode' || model.startsWith('oc-')) {
98
+ throw new Error('reasoning_effort is not supported for opencode.');
99
+ }
100
+
43
101
  const normalized = trimmed.toLowerCase();
44
102
  if (!ALLOWED_REASONING_EFFORTS.has(normalized)) {
45
103
  throw new Error(
46
104
  `Invalid reasoning_effort: ${rawValue}. Allowed values: low, medium, high, xhigh.`
47
105
  );
48
106
  }
49
- const agent = getAgentForModel(model);
107
+ const agent = getStandardAgentForModel(model);
50
108
  if (agent === 'forge') {
51
109
  throw new Error('reasoning_effort is not supported for forge.');
52
110
  }
@@ -67,7 +125,7 @@ export interface CliCommand {
67
125
  cliPath: string;
68
126
  args: string[];
69
127
  cwd: string;
70
- agent: 'claude' | 'codex' | 'gemini' | 'forge';
128
+ agent: Agent;
71
129
  prompt: string;
72
130
  resolvedModel: string;
73
131
  }
@@ -79,21 +137,14 @@ export interface BuildCliCommandOptions {
79
137
  model?: string;
80
138
  session_id?: string;
81
139
  reasoning_effort?: string;
82
- cliPaths: { claude: string; codex: string; gemini: string; forge: string };
140
+ cliPaths: CliPaths;
83
141
  }
84
142
 
85
- /**
86
- * Build a CLI command from the given options.
87
- * This is a pure function (aside from filesystem reads for prompt_file / workFolder validation).
88
- * @throws Error on validation failures
89
- */
90
143
  export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
91
- // Validate workFolder
92
144
  if (!options.workFolder || typeof options.workFolder !== 'string') {
93
145
  throw new Error('Missing or invalid required parameter: workFolder');
94
146
  }
95
147
 
96
- // Validate prompt / prompt_file
97
148
  const hasPrompt = !!options.prompt && typeof options.prompt === 'string' && options.prompt.trim() !== '';
98
149
  const hasPromptFile = !!options.prompt_file && typeof options.prompt_file === 'string' && options.prompt_file.trim() !== '';
99
150
 
@@ -105,7 +156,6 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
105
156
  throw new Error('Cannot specify both prompt and prompt_file. Please use only one.');
106
157
  }
107
158
 
108
- // Determine prompt
109
159
  let prompt: string;
110
160
  if (hasPrompt) {
111
161
  prompt = options.prompt!;
@@ -125,18 +175,14 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
125
175
  }
126
176
  }
127
177
 
128
- // Resolve workFolder
129
178
  const cwd = pathResolve(options.workFolder);
130
179
  if (!existsSync(cwd)) {
131
180
  throw new Error(`Working folder does not exist: ${options.workFolder}`);
132
181
  }
133
182
 
134
- // Resolve model
135
183
  const rawModel = options.model || '';
136
- const resolvedModel = resolveModelAlias(rawModel);
137
- const agent = getAgentForModel(resolvedModel);
184
+ const { agent, resolvedModel, openCodeModel } = resolveModelSelection(rawModel);
138
185
 
139
- // Special handling for ultra aliases: default to higher reasoning if not specified
140
186
  let reasoningEffortArg: string | undefined = options.reasoning_effort;
141
187
  if (!reasoningEffortArg) {
142
188
  if (rawModel === 'codex-ultra') {
@@ -146,9 +192,11 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
146
192
  }
147
193
  }
148
194
 
149
- const reasoningEffort = getReasoningEffort(resolvedModel, reasoningEffortArg);
195
+ const reasoningTargetModel = rawModel === 'opencode' || rawModel.startsWith('oc-')
196
+ ? rawModel
197
+ : (resolvedModel || rawModel);
198
+ const reasoningEffort = getReasoningEffort(reasoningTargetModel, reasoningEffortArg);
150
199
 
151
- // Build CLI path and args
152
200
  let cliPath: string;
153
201
  let args: string[];
154
202
 
@@ -169,10 +217,9 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
169
217
  }
170
218
 
171
219
  args.push('--skip-git-repo-check', '--full-auto', '--json', prompt);
172
-
173
220
  } else if (agent === 'gemini') {
174
221
  cliPath = options.cliPaths.gemini;
175
- args = ['-y', '--output-format', 'json'];
222
+ args = ['-y', '--output-format', 'stream-json'];
176
223
 
177
224
  if (options.session_id && typeof options.session_id === 'string') {
178
225
  args.push('-r', options.session_id);
@@ -183,7 +230,6 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
183
230
  }
184
231
 
185
232
  args.push(prompt);
186
-
187
233
  } else if (agent === 'forge') {
188
234
  cliPath = options.cliPaths.forge;
189
235
  args = ['-C', cwd];
@@ -193,6 +239,19 @@ export function buildCliCommand(options: BuildCliCommandOptions): CliCommand {
193
239
  }
194
240
 
195
241
  args.push('-p', prompt);
242
+ } else if (agent === 'opencode') {
243
+ cliPath = options.cliPaths.opencode;
244
+ args = ['run', '--format', 'json', '--dir', cwd];
245
+
246
+ if (options.session_id && typeof options.session_id === 'string') {
247
+ args.push('--session', options.session_id);
248
+ }
249
+
250
+ if (openCodeModel) {
251
+ args.push('--model', openCodeModel);
252
+ }
253
+
254
+ args.push(prompt);
196
255
  } else {
197
256
  cliPath = options.cliPaths.claude;
198
257
  args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];