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.
- package/.github/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +14 -0
- package/README.ja.md +25 -6
- package/README.md +25 -7
- package/dist/__tests__/app-cli.test.js +24 -4
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +92 -14
- package/dist/__tests__/cli-process-service.test.js +187 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +77 -51
- package/dist/__tests__/mcp-contract.test.js +154 -0
- package/dist/__tests__/parsers.test.js +62 -1
- package/dist/__tests__/process-management.test.js +1 -1
- package/dist/__tests__/server.test.js +35 -6
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +4 -4
- package/dist/app/mcp.js +8 -4
- package/dist/cli-builder.js +66 -27
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +158 -25
- package/dist/cli-utils.js +14 -23
- package/dist/cli.js +6 -4
- package/dist/model-catalog.js +13 -1
- package/dist/parsers.js +57 -26
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +23 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +24 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +110 -14
- package/src/__tests__/cli-process-service.test.ts +217 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +85 -54
- package/src/__tests__/mcp-contract.test.ts +179 -0
- package/src/__tests__/parsers.test.ts +73 -1
- package/src/__tests__/process-management.test.ts +1 -1
- package/src/__tests__/server.test.ts +45 -10
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +4 -4
- package/src/app/mcp.ts +8 -4
- package/src/cli-builder.ts +90 -31
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +193 -22
- package/src/cli-utils.ts +37 -33
- package/src/cli.ts +6 -4
- package/src/model-catalog.ts +24 -1
- package/src/parsers.ts +77 -31
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +28 -15
- package/src/server.ts +2 -2
- package/vitest.config.unit.ts +2 -3
package/src/cli-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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(),
|
|
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
|
}
|