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,7 +3,8 @@ import { resolve as pathResolve, isAbsolute } from 'node:path';
3
3
  import { MODEL_ALIASES } from './model-catalog.js';
4
4
  export const ALLOWED_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
5
5
  const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
6
- function getAgentForModel(model) {
6
+ const OPENCODE_MODEL_ERROR = 'Invalid OpenCode model. Expected exact syntax oc-<provider/model>.';
7
+ function getStandardAgentForModel(model) {
7
8
  if (model === 'forge') {
8
9
  return 'forge';
9
10
  }
@@ -15,19 +16,53 @@ function getAgentForModel(model) {
15
16
  }
16
17
  return 'claude';
17
18
  }
18
- /**
19
- * Resolves model aliases to their full model names
20
- * @param model - The model name or alias to resolve
21
- * @returns The full model name, or the original value if no alias exists
22
- */
19
+ function isPotentialOpenCodeExplicitModel(rawModel) {
20
+ return rawModel.startsWith('oc-') || rawModel.trim().startsWith('oc-');
21
+ }
22
+ function extractOpenCodeModel(rawModel) {
23
+ if (rawModel !== rawModel.trim()) {
24
+ throw new Error(OPENCODE_MODEL_ERROR);
25
+ }
26
+ if (!rawModel.startsWith('oc-')) {
27
+ throw new Error(OPENCODE_MODEL_ERROR);
28
+ }
29
+ const remainder = rawModel.slice(3);
30
+ const slashIndex = remainder.indexOf('/');
31
+ if (slashIndex === -1) {
32
+ throw new Error(OPENCODE_MODEL_ERROR);
33
+ }
34
+ const provider = remainder.slice(0, slashIndex);
35
+ const model = remainder.slice(slashIndex + 1);
36
+ if (!provider || !model) {
37
+ throw new Error(OPENCODE_MODEL_ERROR);
38
+ }
39
+ return remainder;
40
+ }
41
+ function resolveModelSelection(rawModel) {
42
+ if (rawModel === 'opencode') {
43
+ return {
44
+ agent: 'opencode',
45
+ resolvedModel: rawModel,
46
+ openCodeModel: null,
47
+ };
48
+ }
49
+ if (isPotentialOpenCodeExplicitModel(rawModel)) {
50
+ return {
51
+ agent: 'opencode',
52
+ resolvedModel: rawModel,
53
+ openCodeModel: extractOpenCodeModel(rawModel),
54
+ };
55
+ }
56
+ const resolvedModel = resolveModelAlias(rawModel);
57
+ return {
58
+ agent: getStandardAgentForModel(resolvedModel),
59
+ resolvedModel,
60
+ openCodeModel: null,
61
+ };
62
+ }
23
63
  export function resolveModelAlias(model) {
24
64
  return MODEL_ALIASES[model] || model;
25
65
  }
26
- /**
27
- * Validates and normalizes reasoning effort parameter.
28
- * @returns normalized reasoning effort string, or '' if not applicable
29
- * @throws Error for invalid values (plain Error, not MCP-specific)
30
- */
31
66
  export function getReasoningEffort(model, rawValue) {
32
67
  if (typeof rawValue !== 'string') {
33
68
  return '';
@@ -36,11 +71,14 @@ export function getReasoningEffort(model, rawValue) {
36
71
  if (!trimmed) {
37
72
  return '';
38
73
  }
74
+ if (model === 'opencode' || model.startsWith('oc-')) {
75
+ throw new Error('reasoning_effort is not supported for opencode.');
76
+ }
39
77
  const normalized = trimmed.toLowerCase();
40
78
  if (!ALLOWED_REASONING_EFFORTS.has(normalized)) {
41
79
  throw new Error(`Invalid reasoning_effort: ${rawValue}. Allowed values: low, medium, high, xhigh.`);
42
80
  }
43
- const agent = getAgentForModel(model);
81
+ const agent = getStandardAgentForModel(model);
44
82
  if (agent === 'forge') {
45
83
  throw new Error('reasoning_effort is not supported for forge.');
46
84
  }
@@ -52,17 +90,10 @@ export function getReasoningEffort(model, rawValue) {
52
90
  }
53
91
  return normalized;
54
92
  }
55
- /**
56
- * Build a CLI command from the given options.
57
- * This is a pure function (aside from filesystem reads for prompt_file / workFolder validation).
58
- * @throws Error on validation failures
59
- */
60
93
  export function buildCliCommand(options) {
61
- // Validate workFolder
62
94
  if (!options.workFolder || typeof options.workFolder !== 'string') {
63
95
  throw new Error('Missing or invalid required parameter: workFolder');
64
96
  }
65
- // Validate prompt / prompt_file
66
97
  const hasPrompt = !!options.prompt && typeof options.prompt === 'string' && options.prompt.trim() !== '';
67
98
  const hasPromptFile = !!options.prompt_file && typeof options.prompt_file === 'string' && options.prompt_file.trim() !== '';
68
99
  if (!hasPrompt && !hasPromptFile) {
@@ -71,7 +102,6 @@ export function buildCliCommand(options) {
71
102
  if (hasPrompt && hasPromptFile) {
72
103
  throw new Error('Cannot specify both prompt and prompt_file. Please use only one.');
73
104
  }
74
- // Determine prompt
75
105
  let prompt;
76
106
  if (hasPrompt) {
77
107
  prompt = options.prompt;
@@ -90,16 +120,12 @@ export function buildCliCommand(options) {
90
120
  throw new Error(`Failed to read prompt file: ${error.message}`);
91
121
  }
92
122
  }
93
- // Resolve workFolder
94
123
  const cwd = pathResolve(options.workFolder);
95
124
  if (!existsSync(cwd)) {
96
125
  throw new Error(`Working folder does not exist: ${options.workFolder}`);
97
126
  }
98
- // Resolve model
99
127
  const rawModel = options.model || '';
100
- const resolvedModel = resolveModelAlias(rawModel);
101
- const agent = getAgentForModel(resolvedModel);
102
- // Special handling for ultra aliases: default to higher reasoning if not specified
128
+ const { agent, resolvedModel, openCodeModel } = resolveModelSelection(rawModel);
103
129
  let reasoningEffortArg = options.reasoning_effort;
104
130
  if (!reasoningEffortArg) {
105
131
  if (rawModel === 'codex-ultra') {
@@ -109,8 +135,10 @@ export function buildCliCommand(options) {
109
135
  reasoningEffortArg = 'high';
110
136
  }
111
137
  }
112
- const reasoningEffort = getReasoningEffort(resolvedModel, reasoningEffortArg);
113
- // Build CLI path and args
138
+ const reasoningTargetModel = rawModel === 'opencode' || rawModel.startsWith('oc-')
139
+ ? rawModel
140
+ : (resolvedModel || rawModel);
141
+ const reasoningEffort = getReasoningEffort(reasoningTargetModel, reasoningEffortArg);
114
142
  let cliPath;
115
143
  let args;
116
144
  if (agent === 'codex') {
@@ -131,7 +159,7 @@ export function buildCliCommand(options) {
131
159
  }
132
160
  else if (agent === 'gemini') {
133
161
  cliPath = options.cliPaths.gemini;
134
- args = ['-y', '--output-format', 'json'];
162
+ args = ['-y', '--output-format', 'stream-json'];
135
163
  if (options.session_id && typeof options.session_id === 'string') {
136
164
  args.push('-r', options.session_id);
137
165
  }
@@ -148,6 +176,17 @@ export function buildCliCommand(options) {
148
176
  }
149
177
  args.push('-p', prompt);
150
178
  }
179
+ else if (agent === 'opencode') {
180
+ cliPath = options.cliPaths.opencode;
181
+ args = ['run', '--format', 'json', '--dir', cwd];
182
+ if (options.session_id && typeof options.session_id === 'string') {
183
+ args.push('--session', options.session_id);
184
+ }
185
+ if (openCodeModel) {
186
+ args.push('--model', openCodeModel);
187
+ }
188
+ args.push(prompt);
189
+ }
151
190
  else {
152
191
  cliPath = options.cliPaths.claude;
153
192
  args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
package/dist/cli-parse.js CHANGED
@@ -1,18 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
3
- const AGENTS = ['claude', 'codex', 'gemini', 'forge'];
4
- const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge>
2
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
3
+ const AGENTS = ['claude', 'codex', 'gemini', 'forge', 'opencode'];
4
+ const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>
5
5
 
6
6
  Reads raw CLI output from stdin and outputs parsed JSON to stdout.
7
7
 
8
8
  Options:
9
- --agent Agent type: claude, codex, gemini, or forge (required)
9
+ --agent Agent type: claude, codex, gemini, forge, or opencode (required)
10
10
  --help Show this help message
11
11
 
12
12
  Examples:
13
13
  npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" > raw.txt
14
14
  npm run -s cli.run.parse -- --agent claude < raw.txt
15
15
 
16
+ npm run -s cli.run -- --model opencode --workFolder /tmp --prompt "hi" > raw.txt
17
+ npm run -s cli.run.parse -- --agent opencode < raw.txt
18
+
16
19
  # Or pipe directly
17
20
  npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" | npm run -s cli.run.parse -- --agent claude
18
21
  `;
@@ -56,7 +59,7 @@ async function main() {
56
59
  }
57
60
  const agent = args.agent;
58
61
  if (!agent || !AGENTS.includes(agent)) {
59
- process.stderr.write(`Error: --agent is required (claude, codex, gemini, or forge)\n\n`);
62
+ process.stderr.write(`Error: --agent is required (claude, codex, gemini, forge, or opencode)\n\n`);
60
63
  process.stderr.write(USAGE);
61
64
  process.exit(1);
62
65
  }
@@ -79,6 +82,9 @@ async function main() {
79
82
  case 'forge':
80
83
  parsed = parseForgeOutput(input);
81
84
  break;
85
+ case 'opencode':
86
+ parsed = parseOpenCodeOutput(input);
87
+ break;
82
88
  }
83
89
  process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
84
90
  }
@@ -1,11 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
3
3
  import { join, basename, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { buildCliCommand } from './cli-builder.js';
6
- import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
7
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
6
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
7
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
8
8
  import { buildProcessResult } from './process-result.js';
9
+ import { appendPeekMessages, buildNotFoundPeekProcess, observedDurationSec, validatePeekPids, validatePeekTimeSec, } from './peek.js';
9
10
  function resolveDefaultStateDir() {
10
11
  return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
11
12
  }
@@ -27,6 +28,27 @@ function normalizeCwdForStorage(cwd) {
27
28
  .map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
28
29
  .join('');
29
30
  }
31
+ function parseAgentOutput(agent, stdout, stderr) {
32
+ if (agent === 'codex') {
33
+ return parseCodexOutput(`${stdout}\n${stderr}`);
34
+ }
35
+ if (!stdout) {
36
+ return null;
37
+ }
38
+ if (agent === 'claude') {
39
+ return parseClaudeOutput(stdout);
40
+ }
41
+ if (agent === 'gemini') {
42
+ return parseGeminiOutput(stdout);
43
+ }
44
+ if (agent === 'forge') {
45
+ return parseForgeOutput(stdout);
46
+ }
47
+ if (agent === 'opencode') {
48
+ return parseOpenCodeOutput(stdout);
49
+ }
50
+ return null;
51
+ }
30
52
  export class CliProcessService {
31
53
  stateDir;
32
54
  cliPaths;
@@ -37,6 +59,7 @@ export class CliProcessService {
37
59
  codex: findCodexCli(),
38
60
  gemini: findGeminiCli(),
39
61
  forge: findForgeCli(),
62
+ opencode: findOpencodeCli(),
40
63
  };
41
64
  mkdirSync(this.stateDir, { recursive: true });
42
65
  }
@@ -50,6 +73,9 @@ export class CliProcessService {
50
73
  reasoning_effort: options.reasoning_effort,
51
74
  cliPaths: this.cliPaths,
52
75
  });
76
+ if (cmd.agent === 'opencode') {
77
+ return this.startDetachedOpenCodeProcess(cmd, options.model);
78
+ }
53
79
  const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
54
80
  const stderrPath = this.resolveStderrPathForPidPlaceholder();
55
81
  let stdoutFd;
@@ -119,26 +145,12 @@ export class CliProcessService {
119
145
  const refreshed = this.refreshStatus(storedProcess);
120
146
  const stdout = this.readTextFileSafe(refreshed.stdoutPath);
121
147
  const stderr = this.readTextFileSafe(refreshed.stderrPath);
122
- let agentOutput = null;
123
- if (refreshed.toolType === 'codex') {
124
- agentOutput = parseCodexOutput(`${stdout}\n${stderr}`);
125
- }
126
- else if (stdout) {
127
- if (refreshed.toolType === 'claude') {
128
- agentOutput = parseClaudeOutput(stdout);
129
- }
130
- else if (refreshed.toolType === 'gemini') {
131
- agentOutput = parseGeminiOutput(stdout);
132
- }
133
- else if (refreshed.toolType === 'forge') {
134
- agentOutput = parseForgeOutput(stdout);
135
- }
136
- }
148
+ const agentOutput = parseAgentOutput(refreshed.toolType, stdout, stderr);
137
149
  return buildProcessResult({
138
150
  pid,
139
151
  agent: refreshed.toolType,
140
152
  status: refreshed.status,
141
- exitCode: undefined,
153
+ exitCode: refreshed.exitCode,
142
154
  startTime: refreshed.startTime,
143
155
  workFolder: refreshed.workFolder,
144
156
  prompt: refreshed.prompt,
@@ -163,6 +175,79 @@ export class CliProcessService {
163
175
  await new Promise((resolve) => setTimeout(resolve, 50));
164
176
  }
165
177
  }
178
+ async peekProcesses(pids, peekTimeSec = 10) {
179
+ const targetPids = validatePeekPids(pids);
180
+ const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
181
+ const processes = [];
182
+ const observers = [];
183
+ for (const pid of targetPids) {
184
+ let process;
185
+ try {
186
+ process = this.refreshStatus(this.readProcess(pid));
187
+ }
188
+ catch {
189
+ processes.push(buildNotFoundPeekProcess(pid));
190
+ continue;
191
+ }
192
+ const result = {
193
+ pid,
194
+ agent: process.toolType,
195
+ status: process.status,
196
+ messages: [],
197
+ truncated: false,
198
+ error: null,
199
+ };
200
+ processes.push(result);
201
+ observers.push({
202
+ process,
203
+ result,
204
+ stdoutExtractor: new PeekMessageExtractor(process.toolType),
205
+ stderrExtractor: new PeekMessageExtractor(process.toolType),
206
+ stdoutOffset: this.fileSizeSafe(process.stdoutPath),
207
+ stderrOffset: this.fileSizeSafe(process.stderrPath),
208
+ });
209
+ }
210
+ const startedAt = new Date();
211
+ const startedAtMs = Date.now();
212
+ const deadlineMs = startedAtMs + targetPeekTimeSec * 1000;
213
+ while (Date.now() <= deadlineMs) {
214
+ const observedAt = new Date().toISOString();
215
+ let allTerminal = true;
216
+ for (const observer of observers) {
217
+ const stdoutRead = this.readTextFromOffset(observer.process.stdoutPath, observer.stdoutOffset);
218
+ observer.stdoutOffset = stdoutRead.offset;
219
+ appendPeekMessages(observer.result, observer.stdoutExtractor.push(stdoutRead.text, observedAt));
220
+ const stderrRead = this.readTextFromOffset(observer.process.stderrPath, observer.stderrOffset);
221
+ observer.stderrOffset = stderrRead.offset;
222
+ appendPeekMessages(observer.result, observer.stderrExtractor.push(stderrRead.text, observedAt));
223
+ observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
224
+ observer.result.status = observer.process.status;
225
+ if (observer.process.status === 'running') {
226
+ allTerminal = false;
227
+ }
228
+ }
229
+ if (allTerminal) {
230
+ break;
231
+ }
232
+ const remainingMs = deadlineMs - Date.now();
233
+ if (remainingMs <= 0) {
234
+ break;
235
+ }
236
+ await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs)));
237
+ }
238
+ const flushTs = new Date().toISOString();
239
+ for (const observer of observers) {
240
+ observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
241
+ observer.result.status = observer.process.status;
242
+ appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
243
+ appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
244
+ }
245
+ return {
246
+ peek_started_at: startedAt.toISOString(),
247
+ observed_duration_sec: observedDurationSec(startedAtMs),
248
+ processes,
249
+ };
250
+ }
166
251
  async killProcess(pid) {
167
252
  const process = this.readProcess(pid);
168
253
  const refreshed = this.refreshStatus(process);
@@ -209,6 +294,49 @@ export class CliProcessService {
209
294
  message: `Removed ${removed} processes`,
210
295
  };
211
296
  }
297
+ async startDetachedOpenCodeProcess(cmd, model) {
298
+ const cwdKey = this.resolveCwdKey(cmd.cwd);
299
+ const wrapperPath = this.ensureOpenCodeWrapperScript();
300
+ const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
301
+ cwd: cmd.cwd,
302
+ detached: true,
303
+ stdio: 'ignore',
304
+ });
305
+ const pid = childProcess.pid;
306
+ childProcess.unref();
307
+ if (!pid) {
308
+ throw new Error(`Failed to start ${cmd.agent} CLI process`);
309
+ }
310
+ const processDir = this.resolveProcessDir(cmd.cwd, pid);
311
+ mkdirSync(processDir, { recursive: true });
312
+ const stdoutPath = this.resolveStdoutPath(processDir);
313
+ const stderrPath = this.resolveStderrPath(processDir);
314
+ if (!existsSync(stdoutPath)) {
315
+ writeFileSync(stdoutPath, '');
316
+ }
317
+ if (!existsSync(stderrPath)) {
318
+ writeFileSync(stderrPath, '');
319
+ }
320
+ const storedProcess = {
321
+ pid,
322
+ prompt: cmd.prompt,
323
+ workFolder: cmd.cwd,
324
+ cwdKey,
325
+ model,
326
+ toolType: cmd.agent,
327
+ startTime: new Date().toISOString(),
328
+ stdoutPath,
329
+ stderrPath,
330
+ status: 'running',
331
+ };
332
+ this.writeProcess(storedProcess);
333
+ return {
334
+ pid,
335
+ status: 'started',
336
+ agent: cmd.agent,
337
+ message: `${cmd.agent} process started successfully`,
338
+ };
339
+ }
212
340
  readAllProcesses() {
213
341
  const cwdsDir = this.resolveCwdsDir();
214
342
  if (!existsSync(cwdsDir)) {
@@ -246,18 +374,75 @@ export class CliProcessService {
246
374
  writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
247
375
  }
248
376
  refreshStatus(process) {
249
- if (process.status === 'running' && !isProcessRunning(process.pid)) {
377
+ if (process.status !== 'running') {
378
+ return process;
379
+ }
380
+ const persistedExitStatus = this.readExitStatus(process);
381
+ if (persistedExitStatus) {
382
+ process.status = persistedExitStatus.status;
383
+ process.exitCode = persistedExitStatus.exitCode;
384
+ this.writeProcess(process);
385
+ return process;
386
+ }
387
+ if (!isProcessRunning(process.pid)) {
250
388
  process.status = 'completed';
251
389
  this.writeProcess(process);
252
390
  }
253
391
  return process;
254
392
  }
393
+ readExitStatus(process) {
394
+ if (process.toolType !== 'opencode') {
395
+ return null;
396
+ }
397
+ const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
398
+ if (!existsSync(exitMetaPath)) {
399
+ return null;
400
+ }
401
+ try {
402
+ const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8'));
403
+ if (parsed.status === 'completed' || parsed.status === 'failed') {
404
+ return parsed;
405
+ }
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ return null;
411
+ }
255
412
  readTextFileSafe(filePath) {
256
413
  if (!existsSync(filePath)) {
257
414
  return '';
258
415
  }
259
416
  return readFileSync(filePath, 'utf-8');
260
417
  }
418
+ fileSizeSafe(filePath) {
419
+ if (!existsSync(filePath)) {
420
+ return 0;
421
+ }
422
+ return statSync(filePath).size;
423
+ }
424
+ readTextFromOffset(filePath, offset) {
425
+ if (!existsSync(filePath)) {
426
+ return { text: '', offset };
427
+ }
428
+ const size = statSync(filePath).size;
429
+ if (size <= offset) {
430
+ return { text: '', offset: size };
431
+ }
432
+ const fd = openSync(filePath, 'r');
433
+ try {
434
+ const length = size - offset;
435
+ const buffer = Buffer.alloc(length);
436
+ const bytesRead = readSync(fd, buffer, 0, length, offset);
437
+ return {
438
+ text: buffer.subarray(0, bytesRead).toString('utf-8'),
439
+ offset: size,
440
+ };
441
+ }
442
+ finally {
443
+ closeSync(fd);
444
+ }
445
+ }
261
446
  resolveCwdsDir() {
262
447
  return join(this.stateDir, 'cwds');
263
448
  }
@@ -282,12 +467,48 @@ export class CliProcessService {
282
467
  resolveStderrPath(processDir) {
283
468
  return join(processDir, 'stderr.log');
284
469
  }
470
+ resolveExitStatusPath(processDir) {
471
+ return join(processDir, 'exit-status.json');
472
+ }
473
+ resolveOpenCodeWrapperPath() {
474
+ return join(this.stateDir, 'opencode-detached-wrapper.sh');
475
+ }
285
476
  resolveStdoutPathForPidPlaceholder() {
286
477
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
287
478
  }
288
479
  resolveStderrPathForPidPlaceholder() {
289
480
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
290
481
  }
482
+ ensureOpenCodeWrapperScript() {
483
+ const wrapperPath = this.resolveOpenCodeWrapperPath();
484
+ if (existsSync(wrapperPath)) {
485
+ return wrapperPath;
486
+ }
487
+ writeFileSync(wrapperPath, `#!/bin/sh
488
+ set +e
489
+ state_dir="$1"
490
+ cwd_key="$2"
491
+ shift 2
492
+ pid="$$"
493
+ process_dir="$state_dir/cwds/$cwd_key/$pid"
494
+ stdout_path="$process_dir/stdout.log"
495
+ stderr_path="$process_dir/stderr.log"
496
+ exit_meta_path="$process_dir/exit-status.json"
497
+ mkdir -p "$process_dir"
498
+ : > "$stdout_path"
499
+ : > "$stderr_path"
500
+ "$@" >> "$stdout_path" 2>> "$stderr_path"
501
+ exit_code="$?"
502
+ status="completed"
503
+ if [ "$exit_code" -ne 0 ]; then
504
+ status="failed"
505
+ fi
506
+ printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
507
+ exit "$exit_code"
508
+ `);
509
+ chmodSync(wrapperPath, 0o755);
510
+ return wrapperPath;
511
+ }
291
512
  renamePlaceholderFile(fromPath, toPath) {
292
513
  renameSync(fromPath, toPath);
293
514
  }
package/dist/cli-utils.js CHANGED
@@ -2,9 +2,7 @@ import { accessSync, constants } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import * as path from 'path';
5
- // Define debugMode globally using const
6
5
  const debugMode = process.env.MCP_CLAUDE_DEBUG === 'true';
7
- // Dedicated debug logging function
8
6
  export function debugLog(message, ...optionalParams) {
9
7
  if (debugMode) {
10
8
  console.error(message, ...optionalParams);
@@ -77,7 +75,7 @@ function inspectCliBinary(options) {
77
75
  lookup: 'env',
78
76
  };
79
77
  }
80
- if (isExecutableFile(options.localInstallPath)) {
78
+ if (options.localInstallPath && isExecutableFile(options.localInstallPath)) {
81
79
  return {
82
80
  configuredCommand,
83
81
  resolvedPath: options.localInstallPath,
@@ -136,6 +134,13 @@ function getCliBinaryConfig(name) {
136
134
  localInstallPath: join(homedir(), '.forge', 'local', 'forge'),
137
135
  };
138
136
  }
137
+ if (name === 'opencode') {
138
+ return {
139
+ envVarName: 'OPENCODE_CLI_NAME',
140
+ customCliName: process.env.OPENCODE_CLI_NAME,
141
+ defaultCliName: 'opencode',
142
+ };
143
+ }
139
144
  return {
140
145
  envVarName: 'GEMINI_CLI_NAME',
141
146
  customCliName: process.env.GEMINI_CLI_NAME,
@@ -152,43 +157,29 @@ export function getCliDoctorStatus() {
152
157
  codex: getCliBinaryStatus('codex'),
153
158
  gemini: getCliBinaryStatus('gemini'),
154
159
  forge: getCliBinaryStatus('forge'),
160
+ opencode: getCliBinaryStatus('opencode'),
155
161
  };
156
162
  }
157
- /**
158
- * Determine the Gemini CLI command/path.
159
- * Similar to findClaudeCli but for Gemini
160
- */
161
163
  export function findGeminiCli() {
162
164
  debugLog('[Debug] Attempting to find Gemini CLI...');
163
165
  const status = getCliBinaryStatus('gemini');
164
166
  return getCliCommandOrThrow(status);
165
167
  }
166
- /**
167
- * Determine the Codex CLI command/path.
168
- * Similar to findClaudeCli but for Codex
169
- */
170
168
  export function findCodexCli() {
171
169
  debugLog('[Debug] Attempting to find Codex CLI...');
172
170
  const status = getCliBinaryStatus('codex');
173
171
  return getCliCommandOrThrow(status);
174
172
  }
175
- /**
176
- * Determine the Forge CLI command/path.
177
- */
178
173
  export function findForgeCli() {
179
174
  debugLog('[Debug] Attempting to find Forge CLI...');
180
175
  const status = getCliBinaryStatus('forge');
181
176
  return getCliCommandOrThrow(status);
182
177
  }
183
- /**
184
- * Determine the Claude CLI command/path.
185
- * 1. Checks for CLAUDE_CLI_NAME environment variable:
186
- * - If absolute path, uses it directly
187
- * - If relative path, throws error
188
- * - If simple name, continues with path resolution
189
- * 2. Checks for Claude CLI at the local user path: ~/.claude/local/claude.
190
- * 3. If not found, defaults to the CLI name (or 'claude'), relying on the system's PATH for lookup.
191
- */
178
+ export function findOpencodeCli() {
179
+ debugLog('[Debug] Attempting to find OpenCode CLI...');
180
+ const status = getCliBinaryStatus('opencode');
181
+ return getCliCommandOrThrow(status);
182
+ }
192
183
  export function findClaudeCli() {
193
184
  debugLog('[Debug] Attempting to find Claude CLI...');
194
185
  const status = getCliBinaryStatus('claude');
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { buildCliCommand } from './cli-builder.js';
4
- import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
4
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
5
5
  /**
6
6
  * Minimal argv parser. No external dependencies.
7
7
  * Supports: --key value, --key=value
@@ -35,17 +35,18 @@ function parseArgs(argv) {
35
35
  const USAGE = `Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]
36
36
 
37
37
  Options:
38
- --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge)
38
+ --model Model name or alias (e.g. sonnet, opus, gpt-5.2-codex, gemini-2.5-pro, forge, opencode, oc-openai/gpt-5.4)
39
39
  --workFolder Working directory (absolute path)
40
40
  --prompt Prompt string (mutually exclusive with --prompt_file)
41
41
  --prompt_file Path to a file containing the prompt
42
- --session_id Session ID to resume
43
- --reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh
42
+ --session_id Session ID to resume, including OpenCode in-place resumes
43
+ --reasoning_effort Claude/Codex only: Claude=low|medium|high, Codex=low|medium|high|xhigh; unsupported for Gemini, Forge, and OpenCode
44
44
  --help Show this help message
45
45
 
46
46
  Raw CLI output goes to stdout. Use cli.run.parse to parse the output:
47
47
  npm run -s cli.run -- ... > raw.txt
48
48
  npm run -s cli.run.parse -- --agent claude < raw.txt
49
+ npm run -s cli.run.parse -- --agent opencode < raw.txt
49
50
  `;
50
51
  async function main() {
51
52
  const args = parseArgs(process.argv.slice(2));
@@ -69,6 +70,7 @@ async function main() {
69
70
  codex: findCodexCli(),
70
71
  gemini: findGeminiCli(),
71
72
  forge: findForgeCli(),
73
+ opencode: findOpencodeCli(),
72
74
  };
73
75
  // Build command
74
76
  let cmd;