ai-cli-mcp 2.10.0 → 2.12.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/workflows/watch-session-prs.yml +276 -0
- package/CHANGELOG.md +17 -0
- package/README.ja.md +104 -5
- package/README.md +104 -5
- package/dist/__tests__/app-cli.test.js +285 -0
- package/dist/__tests__/cli-bin-smoke.test.js +54 -0
- package/dist/__tests__/cli-builder.test.js +49 -2
- package/dist/__tests__/cli-process-service.test.js +233 -0
- package/dist/__tests__/cli-utils.test.js +109 -0
- package/dist/__tests__/error-cases.test.js +2 -1
- package/dist/__tests__/mcp-contract.test.js +195 -0
- package/dist/__tests__/process-management.test.js +15 -8
- package/dist/__tests__/server.test.js +29 -3
- package/dist/__tests__/validation.test.js +2 -2
- package/dist/__tests__/wait.test.js +31 -0
- package/dist/app/cli.js +304 -0
- package/dist/app/mcp.js +362 -0
- package/dist/bin/ai-cli-mcp.js +6 -0
- package/dist/bin/ai-cli.js +10 -0
- package/dist/cli-builder.js +29 -22
- package/dist/cli-process-service.js +328 -0
- package/dist/cli-utils.js +142 -88
- package/dist/cli.js +1 -1
- package/dist/model-catalog.js +50 -0
- package/dist/process-service.js +198 -0
- package/dist/server.js +3 -577
- package/docs/cli-architecture.md +275 -0
- package/package.json +3 -2
- package/src/__tests__/app-cli.test.ts +362 -0
- package/src/__tests__/cli-bin-smoke.test.ts +71 -0
- package/src/__tests__/cli-builder.test.ts +62 -3
- package/src/__tests__/cli-process-service.test.ts +278 -0
- package/src/__tests__/cli-utils.test.ts +132 -0
- package/src/__tests__/error-cases.test.ts +3 -4
- package/src/__tests__/mcp-contract.test.ts +250 -0
- package/src/__tests__/process-management.test.ts +15 -9
- package/src/__tests__/server.test.ts +27 -6
- package/src/__tests__/validation.test.ts +2 -2
- package/src/__tests__/wait.test.ts +38 -0
- package/src/app/cli.ts +373 -0
- package/src/app/mcp.ts +398 -0
- package/src/bin/ai-cli-mcp.ts +7 -0
- package/src/bin/ai-cli.ts +11 -0
- package/src/cli-builder.ts +32 -22
- package/src/cli-process-service.ts +415 -0
- package/src/cli-utils.ts +185 -99
- package/src/cli.ts +1 -1
- package/src/model-catalog.ts +60 -0
- package/src/process-service.ts +261 -0
- package/src/server.ts +3 -667
- package/.github/workflows/watch-codex-fork-pr.yml +0 -98
package/src/cli-utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
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';
|
|
@@ -13,90 +13,209 @@ export function debugLog(message?: any, ...optionalParams: any[]): void {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// Check for custom CLI name from environment variable
|
|
24
|
-
const customCliName = process.env.GEMINI_CLI_NAME;
|
|
25
|
-
if (customCliName) {
|
|
26
|
-
debugLog(`[Debug] Using custom Gemini CLI name from GEMINI_CLI_NAME: ${customCliName}`);
|
|
16
|
+
export interface CliBinaryStatus {
|
|
17
|
+
configuredCommand: string;
|
|
18
|
+
resolvedPath: string | null;
|
|
19
|
+
available: boolean;
|
|
20
|
+
lookup: 'env' | 'local' | 'path';
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return customCliName;
|
|
32
|
-
}
|
|
24
|
+
function getPathDelimiter(): string {
|
|
25
|
+
return process.platform === 'win32' ? ';' : ':';
|
|
26
|
+
}
|
|
33
27
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
28
|
+
function getPathExtensions(): string[] {
|
|
29
|
+
if (process.platform !== 'win32') {
|
|
30
|
+
return [''];
|
|
38
31
|
}
|
|
39
32
|
|
|
40
|
-
const
|
|
33
|
+
const rawPathext = process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM';
|
|
34
|
+
return ['', ...rawPathext.split(';').filter(Boolean)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findExecutableOnPath(commandName: string): string | null {
|
|
38
|
+
const rawPath = process.env.PATH || '';
|
|
39
|
+
if (!rawPath) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
debugLog(`[Debug] Checking for Gemini CLI at local user path: ${userPath}`);
|
|
43
|
+
const pathEntries = rawPath.split(getPathDelimiter()).filter(Boolean);
|
|
44
|
+
const extensions = getPathExtensions();
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
for (const entry of pathEntries) {
|
|
47
|
+
for (const extension of extensions) {
|
|
48
|
+
const candidate = join(entry, `${commandName}${extension}`);
|
|
49
|
+
if (isExecutableFile(candidate)) {
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
|
|
55
|
-
console.warn(`[Warning] Gemini CLI not found at ~/.gemini/local/gemini. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
|
|
56
|
-
return cliName;
|
|
55
|
+
return null;
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
export function findCodexCli(): string {
|
|
64
|
-
debugLog('[Debug] Attempting to find Codex CLI...');
|
|
58
|
+
function validateCustomCliName(envVarName: string, customCliName: string): string | null {
|
|
59
|
+
if (path.isAbsolute(customCliName)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
if (
|
|
64
|
+
customCliName.startsWith('./') ||
|
|
65
|
+
customCliName.startsWith('../') ||
|
|
66
|
+
customCliName.includes('/')
|
|
67
|
+
) {
|
|
68
|
+
return `Invalid ${envVarName}: Relative paths are not allowed. Use either a simple name (e.g., '${customCliName.split('/').pop() || 'cli'}') or an absolute path (e.g., '/tmp/${customCliName.split('/').pop() || 'cli'}-test')`;
|
|
69
|
+
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function inspectCliBinary(options: {
|
|
75
|
+
envVarName: string;
|
|
76
|
+
customCliName: string | undefined;
|
|
77
|
+
defaultCliName: string;
|
|
78
|
+
localInstallPath: string;
|
|
79
|
+
}): CliBinaryStatus {
|
|
80
|
+
const configuredCommand = options.customCliName || options.defaultCliName;
|
|
81
|
+
|
|
82
|
+
if (options.customCliName) {
|
|
83
|
+
const validationError = validateCustomCliName(options.envVarName, options.customCliName);
|
|
84
|
+
if (validationError) {
|
|
85
|
+
return {
|
|
86
|
+
configuredCommand,
|
|
87
|
+
resolvedPath: null,
|
|
88
|
+
available: false,
|
|
89
|
+
lookup: 'env',
|
|
90
|
+
error: validationError,
|
|
91
|
+
};
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
if (path.isAbsolute(options.customCliName)) {
|
|
95
|
+
return {
|
|
96
|
+
configuredCommand,
|
|
97
|
+
resolvedPath: options.customCliName,
|
|
98
|
+
available: isExecutableFile(options.customCliName),
|
|
99
|
+
lookup: 'env',
|
|
100
|
+
};
|
|
80
101
|
}
|
|
102
|
+
|
|
103
|
+
const resolvedPath = findExecutableOnPath(configuredCommand);
|
|
104
|
+
return {
|
|
105
|
+
configuredCommand,
|
|
106
|
+
resolvedPath,
|
|
107
|
+
available: resolvedPath !== null,
|
|
108
|
+
lookup: 'env',
|
|
109
|
+
};
|
|
81
110
|
}
|
|
82
111
|
|
|
83
|
-
|
|
112
|
+
if (isExecutableFile(options.localInstallPath)) {
|
|
113
|
+
return {
|
|
114
|
+
configuredCommand,
|
|
115
|
+
resolvedPath: options.localInstallPath,
|
|
116
|
+
available: true,
|
|
117
|
+
lookup: 'local',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const resolvedPath = findExecutableOnPath(configuredCommand);
|
|
122
|
+
return {
|
|
123
|
+
configuredCommand,
|
|
124
|
+
resolvedPath,
|
|
125
|
+
available: resolvedPath !== null,
|
|
126
|
+
lookup: 'path',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getCliCommandOrThrow(status: CliBinaryStatus): string {
|
|
131
|
+
if (status.error) {
|
|
132
|
+
throw new Error(status.error);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (status.lookup === 'env' && !path.isAbsolute(status.configuredCommand)) {
|
|
136
|
+
return status.configuredCommand;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return status.resolvedPath || status.configuredCommand;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isExecutableFile(filePath: string): boolean {
|
|
143
|
+
try {
|
|
144
|
+
accessSync(filePath, constants.X_OK);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
84
150
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
151
|
+
type CliBinaryName = 'claude' | 'codex' | 'gemini';
|
|
152
|
+
|
|
153
|
+
function getCliBinaryConfig(name: CliBinaryName): {
|
|
154
|
+
envVarName: string;
|
|
155
|
+
customCliName: string | undefined;
|
|
156
|
+
defaultCliName: string;
|
|
157
|
+
localInstallPath: string;
|
|
158
|
+
} {
|
|
159
|
+
if (name === 'claude') {
|
|
160
|
+
return {
|
|
161
|
+
envVarName: 'CLAUDE_CLI_NAME',
|
|
162
|
+
customCliName: process.env.CLAUDE_CLI_NAME,
|
|
163
|
+
defaultCliName: 'claude',
|
|
164
|
+
localInstallPath: join(homedir(), '.claude', 'local', 'claude'),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
88
167
|
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
168
|
+
if (name === 'codex') {
|
|
169
|
+
return {
|
|
170
|
+
envVarName: 'CODEX_CLI_NAME',
|
|
171
|
+
customCliName: process.env.CODEX_CLI_NAME,
|
|
172
|
+
defaultCliName: 'codex',
|
|
173
|
+
localInstallPath: join(homedir(), '.codex', 'local', 'codex'),
|
|
174
|
+
};
|
|
94
175
|
}
|
|
95
176
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
177
|
+
return {
|
|
178
|
+
envVarName: 'GEMINI_CLI_NAME',
|
|
179
|
+
customCliName: process.env.GEMINI_CLI_NAME,
|
|
180
|
+
defaultCliName: 'gemini',
|
|
181
|
+
localInstallPath: join(homedir(), '.gemini', 'local', 'gemini'),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getCliBinaryStatus(name: CliBinaryName): CliBinaryStatus {
|
|
186
|
+
return inspectCliBinary(getCliBinaryConfig(name));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function getCliDoctorStatus(): {
|
|
190
|
+
claude: CliBinaryStatus;
|
|
191
|
+
codex: CliBinaryStatus;
|
|
192
|
+
gemini: CliBinaryStatus;
|
|
193
|
+
} {
|
|
194
|
+
return {
|
|
195
|
+
claude: getCliBinaryStatus('claude'),
|
|
196
|
+
codex: getCliBinaryStatus('codex'),
|
|
197
|
+
gemini: getCliBinaryStatus('gemini'),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Determine the Gemini CLI command/path.
|
|
203
|
+
* Similar to findClaudeCli but for Gemini
|
|
204
|
+
*/
|
|
205
|
+
export function findGeminiCli(): string {
|
|
206
|
+
debugLog('[Debug] Attempting to find Gemini CLI...');
|
|
207
|
+
const status = getCliBinaryStatus('gemini');
|
|
208
|
+
return getCliCommandOrThrow(status);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Determine the Codex CLI command/path.
|
|
213
|
+
* Similar to findClaudeCli but for Codex
|
|
214
|
+
*/
|
|
215
|
+
export function findCodexCli(): string {
|
|
216
|
+
debugLog('[Debug] Attempting to find Codex CLI...');
|
|
217
|
+
const status = getCliBinaryStatus('codex');
|
|
218
|
+
return getCliCommandOrThrow(status);
|
|
100
219
|
}
|
|
101
220
|
|
|
102
221
|
/**
|
|
@@ -110,39 +229,6 @@ export function findCodexCli(): string {
|
|
|
110
229
|
*/
|
|
111
230
|
export function findClaudeCli(): string {
|
|
112
231
|
debugLog('[Debug] Attempting to find Claude CLI...');
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const customCliName = process.env.CLAUDE_CLI_NAME;
|
|
116
|
-
if (customCliName) {
|
|
117
|
-
debugLog(`[Debug] Using custom Claude CLI name from CLAUDE_CLI_NAME: ${customCliName}`);
|
|
118
|
-
|
|
119
|
-
// If it's an absolute path, use it directly
|
|
120
|
-
if (path.isAbsolute(customCliName)) {
|
|
121
|
-
debugLog(`[Debug] CLAUDE_CLI_NAME is an absolute path: ${customCliName}`);
|
|
122
|
-
return customCliName;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// If it starts with ~ or ./, reject as relative paths are not allowed
|
|
126
|
-
if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
|
|
127
|
-
throw new Error(`Invalid CLAUDE_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'claude') or an absolute path (e.g., '/tmp/claude-test')`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const cliName = customCliName || 'claude';
|
|
132
|
-
|
|
133
|
-
// Try local install path: ~/.claude/local/claude (using the original name for local installs)
|
|
134
|
-
const userPath = join(homedir(), '.claude', 'local', 'claude');
|
|
135
|
-
debugLog(`[Debug] Checking for Claude CLI at local user path: ${userPath}`);
|
|
136
|
-
|
|
137
|
-
if (existsSync(userPath)) {
|
|
138
|
-
debugLog(`[Debug] Found Claude CLI at local user path: ${userPath}. Using this path.`);
|
|
139
|
-
return userPath;
|
|
140
|
-
} else {
|
|
141
|
-
debugLog(`[Debug] Claude CLI not found at local user path: ${userPath}.`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// 3. Fallback to CLI name (PATH lookup)
|
|
145
|
-
debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
|
|
146
|
-
console.warn(`[Warning] Claude CLI not found at ~/.claude/local/claude. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
|
|
147
|
-
return cliName;
|
|
232
|
+
const status = getCliBinaryStatus('claude');
|
|
233
|
+
return getCliCommandOrThrow(status);
|
|
148
234
|
}
|
package/src/cli.ts
CHANGED
|
@@ -40,7 +40,7 @@ Options:
|
|
|
40
40
|
--prompt Prompt string (mutually exclusive with --prompt_file)
|
|
41
41
|
--prompt_file Path to a file containing the prompt
|
|
42
42
|
--session_id Session ID to resume
|
|
43
|
-
--reasoning_effort Codex
|
|
43
|
+
--reasoning_effort Claude/Codex: Claude=low|medium|high, Codex=low|medium|high|xhigh
|
|
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:
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const CLAUDE_MODELS = ['sonnet', 'sonnet[1m]', 'opus', 'opusplan', 'haiku'] as const;
|
|
2
|
+
export const CODEX_MODELS = [
|
|
3
|
+
'gpt-5.4',
|
|
4
|
+
'gpt-5.3-codex',
|
|
5
|
+
'gpt-5.2-codex',
|
|
6
|
+
'gpt-5.1-codex-mini',
|
|
7
|
+
'gpt-5.1-codex-max',
|
|
8
|
+
'gpt-5.2',
|
|
9
|
+
'gpt-5.1',
|
|
10
|
+
'gpt-5.1-codex',
|
|
11
|
+
'gpt-5-codex',
|
|
12
|
+
'gpt-5-codex-mini',
|
|
13
|
+
'gpt-5',
|
|
14
|
+
] as const;
|
|
15
|
+
export const GEMINI_MODELS = [
|
|
16
|
+
'gemini-2.5-pro',
|
|
17
|
+
'gemini-2.5-flash',
|
|
18
|
+
'gemini-3.1-pro-preview',
|
|
19
|
+
'gemini-3-pro-preview',
|
|
20
|
+
'gemini-3-flash-preview',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export const MODEL_ALIASES: Record<string, string> = {
|
|
24
|
+
'claude-ultra': 'opus',
|
|
25
|
+
'codex-ultra': 'gpt-5.4',
|
|
26
|
+
'gemini-ultra': 'gemini-3.1-pro-preview',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const MODEL_ALIAS_DETAILS = [
|
|
30
|
+
{ name: 'claude-ultra', resolvesTo: 'opus', agent: 'claude', defaultReasoningEffort: 'high' },
|
|
31
|
+
{ name: 'codex-ultra', resolvesTo: 'gpt-5.4', agent: 'codex', defaultReasoningEffort: 'xhigh' },
|
|
32
|
+
{ name: 'gemini-ultra', resolvesTo: 'gemini-3.1-pro-preview', agent: 'gemini' },
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export function getSupportedModelsDescription(): string {
|
|
36
|
+
return [
|
|
37
|
+
'"claude-ultra", "codex-ultra", "gemini-ultra"',
|
|
38
|
+
...CLAUDE_MODELS.map((model) => `"${model}"`),
|
|
39
|
+
...CODEX_MODELS.map((model) => `"${model}"`),
|
|
40
|
+
...GEMINI_MODELS.map((model) => `"${model}"`),
|
|
41
|
+
].join(', ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getModelParameterDescription(): string {
|
|
45
|
+
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].map((model) => `"${model}"`).join(', ')}.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getModelsPayload(): {
|
|
49
|
+
aliases: ReadonlyArray<(typeof MODEL_ALIAS_DETAILS)[number]>;
|
|
50
|
+
claude: ReadonlyArray<string>;
|
|
51
|
+
codex: ReadonlyArray<string>;
|
|
52
|
+
gemini: ReadonlyArray<string>;
|
|
53
|
+
} {
|
|
54
|
+
return {
|
|
55
|
+
aliases: MODEL_ALIAS_DETAILS,
|
|
56
|
+
claude: CLAUDE_MODELS,
|
|
57
|
+
codex: CODEX_MODELS,
|
|
58
|
+
gemini: GEMINI_MODELS,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { buildCliCommand, type BuildCliCommandOptions } from './cli-builder.js';
|
|
3
|
+
import { parseClaudeOutput, parseCodexOutput, parseGeminiOutput } from './parsers.js';
|
|
4
|
+
|
|
5
|
+
export type AgentType = 'claude' | 'codex' | 'gemini';
|
|
6
|
+
export type ProcessStatus = 'running' | 'completed' | 'failed';
|
|
7
|
+
|
|
8
|
+
interface TrackedProcess {
|
|
9
|
+
pid: number;
|
|
10
|
+
process: ChildProcess;
|
|
11
|
+
prompt: string;
|
|
12
|
+
workFolder: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
toolType: AgentType;
|
|
15
|
+
startTime: string;
|
|
16
|
+
stdout: string;
|
|
17
|
+
stderr: string;
|
|
18
|
+
status: ProcessStatus;
|
|
19
|
+
exitCode?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProcessListItem {
|
|
23
|
+
pid: number;
|
|
24
|
+
agent: AgentType;
|
|
25
|
+
status: ProcessStatus;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StartProcessResult {
|
|
29
|
+
pid: number;
|
|
30
|
+
status: 'started';
|
|
31
|
+
agent: AgentType;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProcessServiceOptions {
|
|
36
|
+
cliPaths: BuildCliCommandOptions['cliPaths'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ProcessService {
|
|
40
|
+
private readonly processManager = new Map<number, TrackedProcess>();
|
|
41
|
+
private readonly cliPaths: BuildCliCommandOptions['cliPaths'];
|
|
42
|
+
|
|
43
|
+
constructor(options: ProcessServiceOptions) {
|
|
44
|
+
this.cliPaths = options.cliPaths;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
startProcess(options: Omit<BuildCliCommandOptions, 'cliPaths'>): StartProcessResult {
|
|
48
|
+
const cmd = buildCliCommand({
|
|
49
|
+
...options,
|
|
50
|
+
cliPaths: this.cliPaths,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const { cliPath, args: processArgs, cwd: effectiveCwd, agent, prompt } = cmd;
|
|
54
|
+
const childProcess = spawn(cliPath, processArgs, {
|
|
55
|
+
cwd: effectiveCwd,
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
detached: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const pid = childProcess.pid;
|
|
61
|
+
if (!pid) {
|
|
62
|
+
throw new Error(`Failed to start ${agent} CLI process`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const processEntry: TrackedProcess = {
|
|
66
|
+
pid,
|
|
67
|
+
process: childProcess,
|
|
68
|
+
prompt,
|
|
69
|
+
workFolder: effectiveCwd,
|
|
70
|
+
model: options.model,
|
|
71
|
+
toolType: agent,
|
|
72
|
+
startTime: new Date().toISOString(),
|
|
73
|
+
stdout: '',
|
|
74
|
+
stderr: '',
|
|
75
|
+
status: 'running',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.processManager.set(pid, processEntry);
|
|
79
|
+
|
|
80
|
+
childProcess.stdout.on('data', (data) => {
|
|
81
|
+
const entry = this.processManager.get(pid);
|
|
82
|
+
if (entry) {
|
|
83
|
+
entry.stdout += data.toString();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
childProcess.stderr.on('data', (data) => {
|
|
88
|
+
const entry = this.processManager.get(pid);
|
|
89
|
+
if (entry) {
|
|
90
|
+
entry.stderr += data.toString();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
childProcess.on('close', (code) => {
|
|
95
|
+
const entry = this.processManager.get(pid);
|
|
96
|
+
if (entry) {
|
|
97
|
+
entry.status = code === 0 ? 'completed' : 'failed';
|
|
98
|
+
entry.exitCode = code !== null ? code : undefined;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
childProcess.on('error', (error) => {
|
|
103
|
+
const entry = this.processManager.get(pid);
|
|
104
|
+
if (entry) {
|
|
105
|
+
entry.status = 'failed';
|
|
106
|
+
entry.stderr += `\nProcess error: ${error.message}`;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
pid,
|
|
112
|
+
status: 'started',
|
|
113
|
+
agent,
|
|
114
|
+
message: `${agent} process started successfully`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
listProcesses(): ProcessListItem[] {
|
|
119
|
+
const processes: ProcessListItem[] = [];
|
|
120
|
+
|
|
121
|
+
for (const [pid, process] of this.processManager.entries()) {
|
|
122
|
+
processes.push({
|
|
123
|
+
pid,
|
|
124
|
+
agent: process.toolType,
|
|
125
|
+
status: process.status,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return processes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getProcessResult(pid: number, verbose = false): any {
|
|
133
|
+
const process = this.processManager.get(pid);
|
|
134
|
+
if (!process) {
|
|
135
|
+
throw new Error(`Process with PID ${pid} not found`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let agentOutput: any = null;
|
|
139
|
+
if (process.toolType === 'codex') {
|
|
140
|
+
const combinedOutput = (process.stdout || '') + '\n' + (process.stderr || '');
|
|
141
|
+
agentOutput = parseCodexOutput(combinedOutput);
|
|
142
|
+
} else if (process.stdout) {
|
|
143
|
+
if (process.toolType === 'claude') {
|
|
144
|
+
agentOutput = parseClaudeOutput(process.stdout);
|
|
145
|
+
} else if (process.toolType === 'gemini') {
|
|
146
|
+
agentOutput = parseGeminiOutput(process.stdout);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const response: any = {
|
|
151
|
+
pid,
|
|
152
|
+
agent: process.toolType,
|
|
153
|
+
status: process.status,
|
|
154
|
+
exitCode: process.exitCode,
|
|
155
|
+
startTime: process.startTime,
|
|
156
|
+
workFolder: process.workFolder,
|
|
157
|
+
prompt: process.prompt,
|
|
158
|
+
model: process.model,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (agentOutput) {
|
|
162
|
+
if (!verbose && agentOutput.tools) {
|
|
163
|
+
const { tools, ...rest } = agentOutput;
|
|
164
|
+
response.agentOutput = rest;
|
|
165
|
+
} else {
|
|
166
|
+
response.agentOutput = agentOutput;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (agentOutput.session_id) {
|
|
170
|
+
response.session_id = agentOutput.session_id;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
response.stdout = process.stdout;
|
|
174
|
+
response.stderr = process.stderr;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return response;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async waitForProcesses(pids: number[], timeoutSeconds = 180): Promise<any[]> {
|
|
181
|
+
for (const pid of pids) {
|
|
182
|
+
if (!this.processManager.has(pid)) {
|
|
183
|
+
throw new Error(`Process with PID ${pid} not found`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const waitPromises = pids.map((pid) => {
|
|
188
|
+
const processEntry = this.processManager.get(pid)!;
|
|
189
|
+
|
|
190
|
+
if (processEntry.status !== 'running') {
|
|
191
|
+
return Promise.resolve();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return new Promise<void>((resolve) => {
|
|
195
|
+
processEntry.process.once('close', () => {
|
|
196
|
+
resolve();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
202
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
+
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
204
|
+
timeoutHandle = setTimeout(() => {
|
|
205
|
+
reject(new Error(`Timed out after ${timeoutSeconds} seconds waiting for processes`));
|
|
206
|
+
}, timeoutMs);
|
|
207
|
+
timeoutHandle.unref?.();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await Promise.race([Promise.all(waitPromises), timeoutPromise]);
|
|
212
|
+
return pids.map((pid) => this.getProcessResult(pid, false));
|
|
213
|
+
} finally {
|
|
214
|
+
if (timeoutHandle) {
|
|
215
|
+
clearTimeout(timeoutHandle);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
killProcess(pid: number): { pid: number; status: string; message: string } {
|
|
221
|
+
const processEntry = this.processManager.get(pid);
|
|
222
|
+
if (!processEntry) {
|
|
223
|
+
throw new Error(`Process with PID ${pid} not found`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (processEntry.status !== 'running') {
|
|
227
|
+
return {
|
|
228
|
+
pid,
|
|
229
|
+
status: processEntry.status,
|
|
230
|
+
message: 'Process already terminated',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
processEntry.process.kill('SIGTERM');
|
|
235
|
+
processEntry.status = 'failed';
|
|
236
|
+
processEntry.stderr += '\nProcess terminated by user';
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
pid,
|
|
240
|
+
status: 'terminated',
|
|
241
|
+
message: 'Process terminated successfully',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
cleanupProcesses(): { removed: number; removedPids: number[]; message: string } {
|
|
246
|
+
const removedPids: number[] = [];
|
|
247
|
+
|
|
248
|
+
for (const [pid, process] of this.processManager.entries()) {
|
|
249
|
+
if (process.status === 'completed' || process.status === 'failed') {
|
|
250
|
+
removedPids.push(pid);
|
|
251
|
+
this.processManager.delete(pid);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
removed: removedPids.length,
|
|
257
|
+
removedPids,
|
|
258
|
+
message: `Cleaned up ${removedPids.length} finished process(es)`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|