ai-cli-mcp 2.14.0 → 2.15.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 (56) 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 +25 -6
  6. package/README.md +25 -7
  7. package/dist/__tests__/app-cli.test.js +24 -4
  8. package/dist/__tests__/cli-bin-smoke.test.js +43 -0
  9. package/dist/__tests__/cli-builder.test.js +92 -14
  10. package/dist/__tests__/cli-process-service.test.js +187 -0
  11. package/dist/__tests__/cli-utils.test.js +31 -0
  12. package/dist/__tests__/e2e.test.js +77 -51
  13. package/dist/__tests__/mcp-contract.test.js +154 -0
  14. package/dist/__tests__/parsers.test.js +62 -1
  15. package/dist/__tests__/process-management.test.js +1 -1
  16. package/dist/__tests__/server.test.js +35 -6
  17. package/dist/__tests__/utils/opencode-mock.js +91 -0
  18. package/dist/__tests__/validation.test.js +40 -2
  19. package/dist/app/cli.js +4 -4
  20. package/dist/app/mcp.js +8 -4
  21. package/dist/cli-builder.js +66 -27
  22. package/dist/cli-parse.js +11 -5
  23. package/dist/cli-process-service.js +158 -25
  24. package/dist/cli-utils.js +14 -23
  25. package/dist/cli.js +6 -4
  26. package/dist/model-catalog.js +13 -1
  27. package/dist/parsers.js +57 -26
  28. package/dist/process-result.js +9 -2
  29. package/dist/process-service.js +23 -17
  30. package/dist/server.js +1 -2
  31. package/package.json +9 -6
  32. package/src/__tests__/app-cli.test.ts +24 -4
  33. package/src/__tests__/cli-bin-smoke.test.ts +62 -1
  34. package/src/__tests__/cli-builder.test.ts +110 -14
  35. package/src/__tests__/cli-process-service.test.ts +217 -0
  36. package/src/__tests__/cli-utils.test.ts +34 -0
  37. package/src/__tests__/e2e.test.ts +85 -54
  38. package/src/__tests__/mcp-contract.test.ts +179 -0
  39. package/src/__tests__/parsers.test.ts +73 -1
  40. package/src/__tests__/process-management.test.ts +1 -1
  41. package/src/__tests__/server.test.ts +45 -10
  42. package/src/__tests__/utils/opencode-mock.ts +108 -0
  43. package/src/__tests__/validation.test.ts +48 -2
  44. package/src/app/cli.ts +4 -4
  45. package/src/app/mcp.ts +8 -4
  46. package/src/cli-builder.ts +90 -31
  47. package/src/cli-parse.ts +11 -5
  48. package/src/cli-process-service.ts +193 -22
  49. package/src/cli-utils.ts +37 -33
  50. package/src/cli.ts +6 -4
  51. package/src/model-catalog.ts +24 -1
  52. package/src/parsers.ts +77 -31
  53. package/src/process-result.ts +11 -2
  54. package/src/process-service.ts +28 -15
  55. package/src/server.ts +2 -2
  56. package/vitest.config.unit.ts +2 -3
@@ -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,7 +217,6 @@ 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
222
  args = ['-y', '--output-format', 'json'];
@@ -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'];
package/src/cli-parse.ts CHANGED
@@ -1,21 +1,24 @@
1
1
  #!/usr/bin/env node
2
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
2
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
3
3
 
4
- const AGENTS = ['claude', 'codex', 'gemini', 'forge'] as const;
4
+ const AGENTS = ['claude', 'codex', 'gemini', 'forge', 'opencode'] as const;
5
5
  type Agent = typeof AGENTS[number];
6
6
 
7
- const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge>
7
+ const USAGE = `Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>
8
8
 
9
9
  Reads raw CLI output from stdin and outputs parsed JSON to stdout.
10
10
 
11
11
  Options:
12
- --agent Agent type: claude, codex, gemini, or forge (required)
12
+ --agent Agent type: claude, codex, gemini, forge, or opencode (required)
13
13
  --help Show this help message
14
14
 
15
15
  Examples:
16
16
  npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" > raw.txt
17
17
  npm run -s cli.run.parse -- --agent claude < raw.txt
18
18
 
19
+ npm run -s cli.run -- --model opencode --workFolder /tmp --prompt "hi" > raw.txt
20
+ npm run -s cli.run.parse -- --agent opencode < raw.txt
21
+
19
22
  # Or pipe directly
20
23
  npm run -s cli.run -- --model sonnet --workFolder /tmp --prompt "hi" | npm run -s cli.run.parse -- --agent claude
21
24
  `;
@@ -62,7 +65,7 @@ async function main(): Promise<void> {
62
65
 
63
66
  const agent = args.agent as Agent;
64
67
  if (!agent || !AGENTS.includes(agent)) {
65
- process.stderr.write(`Error: --agent is required (claude, codex, gemini, or forge)\n\n`);
68
+ process.stderr.write(`Error: --agent is required (claude, codex, gemini, forge, or opencode)\n\n`);
66
69
  process.stderr.write(USAGE);
67
70
  process.exit(1);
68
71
  }
@@ -88,6 +91,9 @@ async function main(): Promise<void> {
88
91
  case 'forge':
89
92
  parsed = parseForgeOutput(input);
90
93
  break;
94
+ case 'opencode':
95
+ parsed = parseOpenCodeOutput(input);
96
+ break;
91
97
  }
92
98
 
93
99
  process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import {
3
+ chmodSync,
3
4
  closeSync,
4
5
  existsSync,
5
6
  mkdirSync,
@@ -12,11 +13,11 @@ import {
12
13
  unlinkSync,
13
14
  writeFileSync,
14
15
  } from 'node:fs';
15
- import { join } from 'node:path';
16
+ import { join, basename, dirname } from 'node:path';
16
17
  import { homedir } from 'node:os';
17
18
  import { buildCliCommand, type BuildCliCommandOptions } from './cli-builder.js';
18
- import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
19
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
19
+ import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
20
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
20
21
  import { buildProcessResult } from './process-result.js';
21
22
  import type { AgentType, ProcessListItem } from './process-service.js';
22
23
 
@@ -24,12 +25,19 @@ interface StoredProcess {
24
25
  pid: number;
25
26
  prompt: string;
26
27
  workFolder: string;
28
+ cwdKey?: string;
27
29
  model?: string;
28
30
  toolType: AgentType;
29
31
  startTime: string;
30
32
  stdoutPath: string;
31
33
  stderrPath: string;
32
34
  status: 'running' | 'completed' | 'failed';
35
+ exitCode?: number;
36
+ }
37
+
38
+ interface StoredExitStatus {
39
+ status: 'completed' | 'failed';
40
+ exitCode?: number;
33
41
  }
34
42
 
35
43
  interface CliProcessServiceOptions {
@@ -69,6 +77,30 @@ function normalizeCwdForStorage(cwd: string): string {
69
77
  .join('');
70
78
  }
71
79
 
80
+ function parseAgentOutput(agent: AgentType, stdout: string, stderr: string): any {
81
+ if (agent === 'codex') {
82
+ return parseCodexOutput(`${stdout}\n${stderr}`);
83
+ }
84
+
85
+ if (!stdout) {
86
+ return null;
87
+ }
88
+
89
+ if (agent === 'claude') {
90
+ return parseClaudeOutput(stdout);
91
+ }
92
+ if (agent === 'gemini') {
93
+ return parseGeminiOutput(stdout);
94
+ }
95
+ if (agent === 'forge') {
96
+ return parseForgeOutput(stdout);
97
+ }
98
+ if (agent === 'opencode') {
99
+ return parseOpenCodeOutput(stdout);
100
+ }
101
+ return null;
102
+ }
103
+
72
104
  export class CliProcessService {
73
105
  private readonly stateDir: string;
74
106
  private readonly cliPaths: BuildCliCommandOptions['cliPaths'];
@@ -80,6 +112,7 @@ export class CliProcessService {
80
112
  codex: findCodexCli(),
81
113
  gemini: findGeminiCli(),
82
114
  forge: findForgeCli(),
115
+ opencode: findOpencodeCli(),
83
116
  };
84
117
  mkdirSync(this.stateDir, { recursive: true });
85
118
  }
@@ -95,6 +128,10 @@ export class CliProcessService {
95
128
  cliPaths: this.cliPaths,
96
129
  });
97
130
 
131
+ if (cmd.agent === 'opencode') {
132
+ return this.startDetachedOpenCodeProcess(cmd, options.model);
133
+ }
134
+
98
135
  const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
99
136
  const stderrPath = this.resolveStderrPathForPidPlaceholder();
100
137
  let stdoutFd: number | undefined;
@@ -128,6 +165,7 @@ export class CliProcessService {
128
165
  pid,
129
166
  prompt: cmd.prompt,
130
167
  workFolder: cmd.cwd,
168
+ cwdKey: this.resolveCwdKey(cmd.cwd),
131
169
  model: options.model,
132
170
  toolType: cmd.agent,
133
171
  startTime: new Date().toISOString(),
@@ -170,25 +208,13 @@ export class CliProcessService {
170
208
  const refreshed = this.refreshStatus(storedProcess);
171
209
  const stdout = this.readTextFileSafe(refreshed.stdoutPath);
172
210
  const stderr = this.readTextFileSafe(refreshed.stderrPath);
173
-
174
- let agentOutput: any = null;
175
- if (refreshed.toolType === 'codex') {
176
- agentOutput = parseCodexOutput(`${stdout}\n${stderr}`);
177
- } else if (stdout) {
178
- if (refreshed.toolType === 'claude') {
179
- agentOutput = parseClaudeOutput(stdout);
180
- } else if (refreshed.toolType === 'gemini') {
181
- agentOutput = parseGeminiOutput(stdout);
182
- } else if (refreshed.toolType === 'forge') {
183
- agentOutput = parseForgeOutput(stdout);
184
- }
185
- }
211
+ const agentOutput = parseAgentOutput(refreshed.toolType, stdout, stderr);
186
212
 
187
213
  return buildProcessResult({
188
214
  pid,
189
215
  agent: refreshed.toolType,
190
216
  status: refreshed.status,
191
- exitCode: undefined,
217
+ exitCode: refreshed.exitCode,
192
218
  startTime: refreshed.startTime,
193
219
  workFolder: refreshed.workFolder,
194
220
  prompt: refreshed.prompt,
@@ -260,7 +286,7 @@ export class CliProcessService {
260
286
  continue;
261
287
  }
262
288
 
263
- const processDir = this.resolveProcessDir(refreshed.workFolder, refreshed.pid);
289
+ const processDir = this.resolveStoredProcessDir(refreshed);
264
290
  if (existsSync(processDir)) {
265
291
  rmSync(processDir, { recursive: true, force: true });
266
292
  removed++;
@@ -275,6 +301,59 @@ export class CliProcessService {
275
301
  };
276
302
  }
277
303
 
304
+ private async startDetachedOpenCodeProcess(
305
+ cmd: Awaited<ReturnType<typeof buildCliCommand>>,
306
+ model: string | undefined,
307
+ ): Promise<{ pid: number; status: 'started'; agent: AgentType; message: string }> {
308
+ const cwdKey = this.resolveCwdKey(cmd.cwd);
309
+ const wrapperPath = this.ensureOpenCodeWrapperScript();
310
+
311
+ const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
312
+ cwd: cmd.cwd,
313
+ detached: true,
314
+ stdio: 'ignore',
315
+ });
316
+
317
+ const pid = childProcess.pid;
318
+ childProcess.unref();
319
+
320
+ if (!pid) {
321
+ throw new Error(`Failed to start ${cmd.agent} CLI process`);
322
+ }
323
+
324
+ const processDir = this.resolveProcessDir(cmd.cwd, pid);
325
+ mkdirSync(processDir, { recursive: true });
326
+ const stdoutPath = this.resolveStdoutPath(processDir);
327
+ const stderrPath = this.resolveStderrPath(processDir);
328
+ if (!existsSync(stdoutPath)) {
329
+ writeFileSync(stdoutPath, '');
330
+ }
331
+ if (!existsSync(stderrPath)) {
332
+ writeFileSync(stderrPath, '');
333
+ }
334
+
335
+ const storedProcess: StoredProcess = {
336
+ pid,
337
+ prompt: cmd.prompt,
338
+ workFolder: cmd.cwd,
339
+ cwdKey,
340
+ model,
341
+ toolType: cmd.agent,
342
+ startTime: new Date().toISOString(),
343
+ stdoutPath,
344
+ stderrPath,
345
+ status: 'running',
346
+ };
347
+ this.writeProcess(storedProcess);
348
+
349
+ return {
350
+ pid,
351
+ status: 'started',
352
+ agent: cmd.agent,
353
+ message: `${cmd.agent} process started successfully`,
354
+ };
355
+ }
356
+
278
357
  private readAllProcesses(): StoredProcess[] {
279
358
  const cwdsDir = this.resolveCwdsDir();
280
359
  if (!existsSync(cwdsDir)) {
@@ -304,23 +383,61 @@ export class CliProcessService {
304
383
  }
305
384
 
306
385
  private parseProcessFile(metaPath: string): StoredProcess {
307
- return JSON.parse(readFileSync(metaPath, 'utf-8')) as StoredProcess;
386
+ const process = JSON.parse(readFileSync(metaPath, 'utf-8')) as StoredProcess;
387
+ if (!process.cwdKey) {
388
+ process.cwdKey = basename(dirname(dirname(metaPath)));
389
+ }
390
+ return process;
308
391
  }
309
392
 
310
393
  private writeProcess(process: StoredProcess): void {
311
- const processDir = this.resolveProcessDir(process.workFolder, process.pid);
394
+ const processDir = this.resolveStoredProcessDir(process);
312
395
  mkdirSync(processDir, { recursive: true });
313
396
  writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
314
397
  }
315
398
 
316
399
  private refreshStatus(process: StoredProcess): StoredProcess {
317
- if (process.status === 'running' && !isProcessRunning(process.pid)) {
400
+ if (process.status !== 'running') {
401
+ return process;
402
+ }
403
+
404
+ const persistedExitStatus = this.readExitStatus(process);
405
+ if (persistedExitStatus) {
406
+ process.status = persistedExitStatus.status;
407
+ process.exitCode = persistedExitStatus.exitCode;
408
+ this.writeProcess(process);
409
+ return process;
410
+ }
411
+
412
+ if (!isProcessRunning(process.pid)) {
318
413
  process.status = 'completed';
319
414
  this.writeProcess(process);
320
415
  }
321
416
  return process;
322
417
  }
323
418
 
419
+ private readExitStatus(process: StoredProcess): StoredExitStatus | null {
420
+ if (process.toolType !== 'opencode') {
421
+ return null;
422
+ }
423
+
424
+ const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
425
+ if (!existsSync(exitMetaPath)) {
426
+ return null;
427
+ }
428
+
429
+ try {
430
+ const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8')) as StoredExitStatus;
431
+ if (parsed.status === 'completed' || parsed.status === 'failed') {
432
+ return parsed;
433
+ }
434
+ } catch {
435
+ return null;
436
+ }
437
+
438
+ return null;
439
+ }
440
+
324
441
  private readTextFileSafe(filePath: string): string {
325
442
  if (!existsSync(filePath)) {
326
443
  return '';
@@ -333,7 +450,18 @@ export class CliProcessService {
333
450
  }
334
451
 
335
452
  private resolveProcessDir(cwd: string, pid: number): string {
336
- return join(this.resolveCwdsDir(), normalizeCwdForStorage(realpathSync(cwd)), String(pid));
453
+ return join(this.resolveCwdsDir(), this.resolveCwdKey(cwd), String(pid));
454
+ }
455
+
456
+ private resolveStoredProcessDir(process: StoredProcess): string {
457
+ if (!process.cwdKey) {
458
+ process.cwdKey = this.resolveCwdKey(process.workFolder);
459
+ }
460
+ return join(this.resolveCwdsDir(), process.cwdKey, String(process.pid));
461
+ }
462
+
463
+ private resolveCwdKey(cwd: string): string {
464
+ return normalizeCwdForStorage(realpathSync(cwd));
337
465
  }
338
466
 
339
467
  private resolveMetaPath(processDir: string): string {
@@ -348,6 +476,14 @@ export class CliProcessService {
348
476
  return join(processDir, 'stderr.log');
349
477
  }
350
478
 
479
+ private resolveExitStatusPath(processDir: string): string {
480
+ return join(processDir, 'exit-status.json');
481
+ }
482
+
483
+ private resolveOpenCodeWrapperPath(): string {
484
+ return join(this.stateDir, 'opencode-detached-wrapper.sh');
485
+ }
486
+
351
487
  private resolveStdoutPathForPidPlaceholder(): string {
352
488
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
353
489
  }
@@ -356,6 +492,41 @@ export class CliProcessService {
356
492
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
357
493
  }
358
494
 
495
+ private ensureOpenCodeWrapperScript(): string {
496
+ const wrapperPath = this.resolveOpenCodeWrapperPath();
497
+ if (existsSync(wrapperPath)) {
498
+ return wrapperPath;
499
+ }
500
+
501
+ writeFileSync(
502
+ wrapperPath,
503
+ `#!/bin/sh
504
+ set +e
505
+ state_dir="$1"
506
+ cwd_key="$2"
507
+ shift 2
508
+ pid="$$"
509
+ process_dir="$state_dir/cwds/$cwd_key/$pid"
510
+ stdout_path="$process_dir/stdout.log"
511
+ stderr_path="$process_dir/stderr.log"
512
+ exit_meta_path="$process_dir/exit-status.json"
513
+ mkdir -p "$process_dir"
514
+ : > "$stdout_path"
515
+ : > "$stderr_path"
516
+ "$@" >> "$stdout_path" 2>> "$stderr_path"
517
+ exit_code="$?"
518
+ status="completed"
519
+ if [ "$exit_code" -ne 0 ]; then
520
+ status="failed"
521
+ fi
522
+ printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
523
+ exit "$exit_code"
524
+ `,
525
+ );
526
+ chmodSync(wrapperPath, 0o755);
527
+ return wrapperPath;
528
+ }
529
+
359
530
  private renamePlaceholderFile(fromPath: string, toPath: string): void {
360
531
  renameSync(fromPath, toPath);
361
532
  }