ai-cli-mcp 2.14.1 → 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 +7 -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 +103 -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 +139 -20
  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 +112 -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 +171 -17
  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,10 +1,10 @@
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, readFileSync, readdirSync, realpathSync, renameSync, rmSync, 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 } from './parsers.js';
8
8
  import { buildProcessResult } from './process-result.js';
9
9
  function resolveDefaultStateDir() {
10
10
  return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
@@ -27,6 +27,27 @@ function normalizeCwdForStorage(cwd) {
27
27
  .map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
28
28
  .join('');
29
29
  }
30
+ function parseAgentOutput(agent, stdout, stderr) {
31
+ if (agent === 'codex') {
32
+ return parseCodexOutput(`${stdout}\n${stderr}`);
33
+ }
34
+ if (!stdout) {
35
+ return null;
36
+ }
37
+ if (agent === 'claude') {
38
+ return parseClaudeOutput(stdout);
39
+ }
40
+ if (agent === 'gemini') {
41
+ return parseGeminiOutput(stdout);
42
+ }
43
+ if (agent === 'forge') {
44
+ return parseForgeOutput(stdout);
45
+ }
46
+ if (agent === 'opencode') {
47
+ return parseOpenCodeOutput(stdout);
48
+ }
49
+ return null;
50
+ }
30
51
  export class CliProcessService {
31
52
  stateDir;
32
53
  cliPaths;
@@ -37,6 +58,7 @@ export class CliProcessService {
37
58
  codex: findCodexCli(),
38
59
  gemini: findGeminiCli(),
39
60
  forge: findForgeCli(),
61
+ opencode: findOpencodeCli(),
40
62
  };
41
63
  mkdirSync(this.stateDir, { recursive: true });
42
64
  }
@@ -50,6 +72,9 @@ export class CliProcessService {
50
72
  reasoning_effort: options.reasoning_effort,
51
73
  cliPaths: this.cliPaths,
52
74
  });
75
+ if (cmd.agent === 'opencode') {
76
+ return this.startDetachedOpenCodeProcess(cmd, options.model);
77
+ }
53
78
  const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
54
79
  const stderrPath = this.resolveStderrPathForPidPlaceholder();
55
80
  let stdoutFd;
@@ -119,26 +144,12 @@ export class CliProcessService {
119
144
  const refreshed = this.refreshStatus(storedProcess);
120
145
  const stdout = this.readTextFileSafe(refreshed.stdoutPath);
121
146
  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
- }
147
+ const agentOutput = parseAgentOutput(refreshed.toolType, stdout, stderr);
137
148
  return buildProcessResult({
138
149
  pid,
139
150
  agent: refreshed.toolType,
140
151
  status: refreshed.status,
141
- exitCode: undefined,
152
+ exitCode: refreshed.exitCode,
142
153
  startTime: refreshed.startTime,
143
154
  workFolder: refreshed.workFolder,
144
155
  prompt: refreshed.prompt,
@@ -209,6 +220,49 @@ export class CliProcessService {
209
220
  message: `Removed ${removed} processes`,
210
221
  };
211
222
  }
223
+ async startDetachedOpenCodeProcess(cmd, model) {
224
+ const cwdKey = this.resolveCwdKey(cmd.cwd);
225
+ const wrapperPath = this.ensureOpenCodeWrapperScript();
226
+ const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
227
+ cwd: cmd.cwd,
228
+ detached: true,
229
+ stdio: 'ignore',
230
+ });
231
+ const pid = childProcess.pid;
232
+ childProcess.unref();
233
+ if (!pid) {
234
+ throw new Error(`Failed to start ${cmd.agent} CLI process`);
235
+ }
236
+ const processDir = this.resolveProcessDir(cmd.cwd, pid);
237
+ mkdirSync(processDir, { recursive: true });
238
+ const stdoutPath = this.resolveStdoutPath(processDir);
239
+ const stderrPath = this.resolveStderrPath(processDir);
240
+ if (!existsSync(stdoutPath)) {
241
+ writeFileSync(stdoutPath, '');
242
+ }
243
+ if (!existsSync(stderrPath)) {
244
+ writeFileSync(stderrPath, '');
245
+ }
246
+ const storedProcess = {
247
+ pid,
248
+ prompt: cmd.prompt,
249
+ workFolder: cmd.cwd,
250
+ cwdKey,
251
+ model,
252
+ toolType: cmd.agent,
253
+ startTime: new Date().toISOString(),
254
+ stdoutPath,
255
+ stderrPath,
256
+ status: 'running',
257
+ };
258
+ this.writeProcess(storedProcess);
259
+ return {
260
+ pid,
261
+ status: 'started',
262
+ agent: cmd.agent,
263
+ message: `${cmd.agent} process started successfully`,
264
+ };
265
+ }
212
266
  readAllProcesses() {
213
267
  const cwdsDir = this.resolveCwdsDir();
214
268
  if (!existsSync(cwdsDir)) {
@@ -246,12 +300,41 @@ export class CliProcessService {
246
300
  writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
247
301
  }
248
302
  refreshStatus(process) {
249
- if (process.status === 'running' && !isProcessRunning(process.pid)) {
303
+ if (process.status !== 'running') {
304
+ return process;
305
+ }
306
+ const persistedExitStatus = this.readExitStatus(process);
307
+ if (persistedExitStatus) {
308
+ process.status = persistedExitStatus.status;
309
+ process.exitCode = persistedExitStatus.exitCode;
310
+ this.writeProcess(process);
311
+ return process;
312
+ }
313
+ if (!isProcessRunning(process.pid)) {
250
314
  process.status = 'completed';
251
315
  this.writeProcess(process);
252
316
  }
253
317
  return process;
254
318
  }
319
+ readExitStatus(process) {
320
+ if (process.toolType !== 'opencode') {
321
+ return null;
322
+ }
323
+ const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
324
+ if (!existsSync(exitMetaPath)) {
325
+ return null;
326
+ }
327
+ try {
328
+ const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8'));
329
+ if (parsed.status === 'completed' || parsed.status === 'failed') {
330
+ return parsed;
331
+ }
332
+ }
333
+ catch {
334
+ return null;
335
+ }
336
+ return null;
337
+ }
255
338
  readTextFileSafe(filePath) {
256
339
  if (!existsSync(filePath)) {
257
340
  return '';
@@ -282,12 +365,48 @@ export class CliProcessService {
282
365
  resolveStderrPath(processDir) {
283
366
  return join(processDir, 'stderr.log');
284
367
  }
368
+ resolveExitStatusPath(processDir) {
369
+ return join(processDir, 'exit-status.json');
370
+ }
371
+ resolveOpenCodeWrapperPath() {
372
+ return join(this.stateDir, 'opencode-detached-wrapper.sh');
373
+ }
285
374
  resolveStdoutPathForPidPlaceholder() {
286
375
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
287
376
  }
288
377
  resolveStderrPathForPidPlaceholder() {
289
378
  return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
290
379
  }
380
+ ensureOpenCodeWrapperScript() {
381
+ const wrapperPath = this.resolveOpenCodeWrapperPath();
382
+ if (existsSync(wrapperPath)) {
383
+ return wrapperPath;
384
+ }
385
+ writeFileSync(wrapperPath, `#!/bin/sh
386
+ set +e
387
+ state_dir="$1"
388
+ cwd_key="$2"
389
+ shift 2
390
+ pid="$$"
391
+ process_dir="$state_dir/cwds/$cwd_key/$pid"
392
+ stdout_path="$process_dir/stdout.log"
393
+ stderr_path="$process_dir/stderr.log"
394
+ exit_meta_path="$process_dir/exit-status.json"
395
+ mkdir -p "$process_dir"
396
+ : > "$stdout_path"
397
+ : > "$stderr_path"
398
+ "$@" >> "$stdout_path" 2>> "$stderr_path"
399
+ exit_code="$?"
400
+ status="completed"
401
+ if [ "$exit_code" -ne 0 ]; then
402
+ status="failed"
403
+ fi
404
+ printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
405
+ exit "$exit_code"
406
+ `);
407
+ chmodSync(wrapperPath, 0o755);
408
+ return wrapperPath;
409
+ }
291
410
  renamePlaceholderFile(fromPath, toPath) {
292
411
  renameSync(fromPath, toPath);
293
412
  }
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;
@@ -20,6 +20,7 @@ export const GEMINI_MODELS = [
20
20
  'gemini-3-flash-preview',
21
21
  ];
22
22
  export const FORGE_MODELS = ['forge'];
23
+ export const OPENCODE_MODELS = ['opencode'];
23
24
  export const MODEL_ALIASES = {
24
25
  'claude-ultra': 'opus',
25
26
  'codex-ultra': 'gpt-5.4',
@@ -37,10 +38,12 @@ export function getSupportedModelsDescription() {
37
38
  ...CODEX_MODELS.map((model) => `"${model}"`),
38
39
  ...GEMINI_MODELS.map((model) => `"${model}"`),
39
40
  ...FORGE_MODELS.map((model) => `"${model}"`),
41
+ ...OPENCODE_MODELS.map((model) => `"${model}"`),
42
+ '"oc-<provider/model>"',
40
43
  ].join(', ');
41
44
  }
42
45
  export function getModelParameterDescription() {
43
- return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS].map((model) => `"${model}"`).join(', ')}. "forge" is a provider key, not a Forge model family selector.`;
46
+ return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS, ...OPENCODE_MODELS].map((model) => `"${model}"`).join(', ')}. OpenCode also accepts explicit dynamic models using "oc-<provider/model>". "forge" is a provider key, not a Forge model family selector.`;
44
47
  }
45
48
  export function getModelsPayload() {
46
49
  return {
@@ -49,5 +52,14 @@ export function getModelsPayload() {
49
52
  codex: CODEX_MODELS,
50
53
  gemini: GEMINI_MODELS,
51
54
  forge: FORGE_MODELS,
55
+ opencode: OPENCODE_MODELS,
56
+ dynamicModelBackends: {
57
+ opencode: {
58
+ explicitPrefix: 'oc-',
59
+ explicitPattern: 'oc-<provider/model>',
60
+ discoveryCommand: 'opencode models',
61
+ modelsAreDynamic: true,
62
+ },
63
+ },
52
64
  };
53
65
  }
package/dist/parsers.js CHANGED
@@ -1,7 +1,4 @@
1
1
  import { debugLog } from './cli-utils.js';
2
- /**
3
- * Parse Codex NDJSON output to extract the last agent message and token count
4
- */
5
2
  export function parseCodexOutput(stdout) {
6
3
  if (!stdout)
7
4
  return null;
@@ -25,7 +22,6 @@ export function parseCodexOutput(stdout) {
25
22
  lastMessage = parsed.msg.message;
26
23
  }
27
24
  else if (parsed.item?.type === 'reasoning') {
28
- // Ignore reasoning-only items for message selection.
29
25
  }
30
26
  else if (parsed.msg?.type === 'token_count') {
31
27
  tokenCount = parsed.msg;
@@ -34,7 +30,7 @@ export function parseCodexOutput(stdout) {
34
30
  tools.push({
35
31
  server: parsed.item.server,
36
32
  tool: parsed.item.tool,
37
- input: parsed.item.arguments, // Map arguments to input to match common patterns
33
+ input: parsed.item.arguments,
38
34
  output: parsed.item.result
39
35
  });
40
36
  }
@@ -48,7 +44,6 @@ export function parseCodexOutput(stdout) {
48
44
  }
49
45
  }
50
46
  catch (e) {
51
- // Skip invalid JSON lines
52
47
  debugLog(`[Debug] Skipping invalid JSON line: ${line}`);
53
48
  }
54
49
  }
@@ -67,56 +62,46 @@ export function parseCodexOutput(stdout) {
67
62
  }
68
63
  return null;
69
64
  }
70
- /**
71
- * Parse Claude Output (supports both JSON and stream-json/NDJSON)
72
- */
73
65
  export function parseClaudeOutput(stdout) {
74
66
  if (!stdout)
75
67
  return null;
76
- // First try parsing as a single JSON object (backward compatibility)
77
68
  try {
78
69
  return JSON.parse(stdout);
79
70
  }
80
71
  catch (e) {
81
- // If not valid single JSON, proceed to parse as NDJSON
82
72
  }
83
73
  try {
84
74
  const lines = stdout.trim().split('\n');
85
75
  let lastMessage = null;
86
76
  let sessionId = null;
87
- const toolsMap = new Map(); // Map by tool_use id for matching results
77
+ const toolsMap = new Map();
88
78
  for (const line of lines) {
89
79
  if (!line.trim())
90
80
  continue;
91
81
  try {
92
82
  const parsed = JSON.parse(line);
93
- // Extract session ID from any message that has it
94
83
  if (parsed.session_id) {
95
84
  sessionId = parsed.session_id;
96
85
  }
97
- // Extract final result message
98
86
  if (parsed.type === 'result' && parsed.result) {
99
87
  lastMessage = parsed.result;
100
88
  }
101
- // Extract tool usage from assistant messages
102
89
  if (parsed.type === 'assistant' && parsed.message?.content) {
103
90
  for (const content of parsed.message.content) {
104
91
  if (content.type === 'tool_use') {
105
92
  toolsMap.set(content.id, {
106
93
  tool: content.name,
107
94
  input: content.input,
108
- output: null // Will be filled when tool_result is found
95
+ output: null
109
96
  });
110
97
  }
111
98
  }
112
99
  }
113
- // Match tool results from user messages
114
100
  if (parsed.type === 'user' && parsed.message?.content) {
115
101
  for (const content of parsed.message.content) {
116
102
  if (content.type === 'tool_result' && content.tool_use_id) {
117
103
  const tool = toolsMap.get(content.tool_use_id);
118
104
  if (tool) {
119
- // Extract text from content array
120
105
  if (Array.isArray(content.content)) {
121
106
  const textContent = content.content.find((c) => c.type === 'text');
122
107
  tool.output = textContent?.text || null;
@@ -133,11 +118,10 @@ export function parseClaudeOutput(stdout) {
133
118
  debugLog(`[Debug] Skipping invalid JSON line in Claude output: ${line}`);
134
119
  }
135
120
  }
136
- // Convert Map to array
137
121
  const tools = Array.from(toolsMap.values());
138
122
  if (lastMessage || sessionId || tools.length > 0) {
139
123
  return {
140
- message: lastMessage, // This is the final result text
124
+ message: lastMessage,
141
125
  session_id: sessionId,
142
126
  tools: tools.length > 0 ? tools : undefined
143
127
  };
@@ -149,9 +133,6 @@ export function parseClaudeOutput(stdout) {
149
133
  }
150
134
  return null;
151
135
  }
152
- /**
153
- * Parse Gemini JSON output
154
- */
155
136
  export function parseGeminiOutput(stdout) {
156
137
  if (!stdout)
157
138
  return null;
@@ -163,9 +144,6 @@ export function parseGeminiOutput(stdout) {
163
144
  return null;
164
145
  }
165
146
  }
166
- /**
167
- * Parse Forge output framed by Initialize/Continue/Finished markers.
168
- */
169
147
  export function parseForgeOutput(stdout) {
170
148
  if (!stdout)
171
149
  return null;
@@ -218,3 +196,56 @@ export function parseForgeOutput(stdout) {
218
196
  session_id: lastConversationId,
219
197
  };
220
198
  }
199
+ export function parseOpenCodeOutput(stdout) {
200
+ if (!stdout) {
201
+ return null;
202
+ }
203
+ let sessionId = null;
204
+ let currentStepBuffer = '';
205
+ let latestCompletedStep = null;
206
+ let hasStepFinish = false;
207
+ let hasParseableAssistantText = false;
208
+ for (const line of stdout.split('\n')) {
209
+ if (!line.trim()) {
210
+ continue;
211
+ }
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(line);
215
+ }
216
+ catch {
217
+ continue;
218
+ }
219
+ if (typeof parsed.sessionID === 'string' && parsed.sessionID) {
220
+ sessionId = parsed.sessionID;
221
+ }
222
+ if (parsed.type === 'step_start') {
223
+ currentStepBuffer = '';
224
+ continue;
225
+ }
226
+ if (parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string') {
227
+ currentStepBuffer += parsed.part.text;
228
+ hasParseableAssistantText = true;
229
+ continue;
230
+ }
231
+ if (parsed.type === 'step_finish') {
232
+ hasStepFinish = true;
233
+ latestCompletedStep = {
234
+ message: currentStepBuffer,
235
+ session_id: sessionId || undefined,
236
+ tokens: parsed.part?.tokens,
237
+ cost: parsed.part?.cost,
238
+ };
239
+ }
240
+ }
241
+ if (hasStepFinish && latestCompletedStep) {
242
+ return latestCompletedStep;
243
+ }
244
+ if (hasParseableAssistantText) {
245
+ return {
246
+ message: currentStepBuffer,
247
+ session_id: sessionId || undefined,
248
+ };
249
+ }
250
+ return null;
251
+ }
@@ -23,6 +23,9 @@ function hasMeaningfulParsedOutput(agentOutput) {
23
23
  return true;
24
24
  });
25
25
  }
26
+ function shouldPreserveRawFailureOutput(context) {
27
+ return context.agent === 'opencode' && context.status === 'failed';
28
+ }
26
29
  export function buildProcessResult(context, agentOutput, verbose = false) {
27
30
  const response = {
28
31
  pid: context.pid,
@@ -40,12 +43,16 @@ export function buildProcessResult(context, agentOutput, verbose = false) {
40
43
  response.session_id = agentOutput.session_id;
41
44
  }
42
45
  const shapedAgentOutput = verbose ? agentOutput : compactAgentOutput(agentOutput);
43
- if (hasMeaningfulParsedOutput(shapedAgentOutput)) {
46
+ const preserveRawFailureOutput = shouldPreserveRawFailureOutput(context);
47
+ if (hasMeaningfulParsedOutput(shapedAgentOutput) && (verbose || !preserveRawFailureOutput)) {
44
48
  response.agentOutput = shapedAgentOutput;
45
49
  }
46
- if (!response.agentOutput) {
50
+ if (!response.agentOutput || preserveRawFailureOutput) {
47
51
  response.stdout = context.stdout;
48
52
  response.stderr = context.stderr;
49
53
  }
54
+ if (verbose && preserveRawFailureOutput && hasMeaningfulParsedOutput(shapedAgentOutput)) {
55
+ response.agentOutput = shapedAgentOutput;
56
+ }
50
57
  return response;
51
58
  }
@@ -1,7 +1,28 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { buildCliCommand } from './cli-builder.js';
3
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
3
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput } from './parsers.js';
4
4
  import { buildProcessResult } from './process-result.js';
5
+ function parseAgentOutput(agent, stdout, stderr) {
6
+ if (agent === 'codex') {
7
+ return parseCodexOutput(`${stdout || ''}\n${stderr || ''}`);
8
+ }
9
+ if (!stdout) {
10
+ return null;
11
+ }
12
+ if (agent === 'claude') {
13
+ return parseClaudeOutput(stdout);
14
+ }
15
+ if (agent === 'gemini') {
16
+ return parseGeminiOutput(stdout);
17
+ }
18
+ if (agent === 'forge') {
19
+ return parseForgeOutput(stdout);
20
+ }
21
+ if (agent === 'opencode') {
22
+ return parseOpenCodeOutput(stdout);
23
+ }
24
+ return null;
25
+ }
5
26
  export class ProcessService {
6
27
  processManager = new Map();
7
28
  cliPaths;
@@ -85,22 +106,7 @@ export class ProcessService {
85
106
  if (!process) {
86
107
  throw new Error(`Process with PID ${pid} not found`);
87
108
  }
88
- let agentOutput = null;
89
- if (process.toolType === 'codex') {
90
- const combinedOutput = (process.stdout || '') + '\n' + (process.stderr || '');
91
- agentOutput = parseCodexOutput(combinedOutput);
92
- }
93
- else if (process.stdout) {
94
- if (process.toolType === 'claude') {
95
- agentOutput = parseClaudeOutput(process.stdout);
96
- }
97
- else if (process.toolType === 'gemini') {
98
- agentOutput = parseGeminiOutput(process.stdout);
99
- }
100
- else if (process.toolType === 'forge') {
101
- agentOutput = parseForgeOutput(process.stdout);
102
- }
103
- }
109
+ const agentOutput = parseAgentOutput(process.toolType, process.stdout, process.stderr);
104
110
  return buildProcessResult({
105
111
  pid,
106
112
  agent: process.toolType,
package/dist/server.js CHANGED
@@ -1,5 +1,4 @@
1
- #!/usr/bin/env node
2
- export { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
1
+ export { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
3
2
  export { resolveModelAlias } from './cli-builder.js';
4
3
  export { ClaudeCodeServer, runMcpServer, spawnAsync } from './app/mcp.js';
5
4
  import { runMcpServer } from './app/mcp.js';