agent-pool-mcp 1.0.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/LICENSE +21 -0
- package/README.md +211 -0
- package/index.js +38 -0
- package/package.json +44 -0
- package/policies/read-only.yaml +13 -0
- package/policies/safe-edit.yaml +7 -0
- package/skills/code-reviewer.md +37 -0
- package/skills/doc-fixer.md +21 -0
- package/skills/orchestrator.md +77 -0
- package/skills/test-writer.md +25 -0
- package/src/cli.js +298 -0
- package/src/runner/config.js +92 -0
- package/src/runner/gemini-runner.js +273 -0
- package/src/runner/process-manager.js +136 -0
- package/src/runner/ssh.js +72 -0
- package/src/server.js +350 -0
- package/src/tool-definitions.js +176 -0
- package/src/tools/consult.js +99 -0
- package/src/tools/results.js +416 -0
- package/src/tools/skills.js +281 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI commands — doctor check, config init, version display.
|
|
3
|
+
*
|
|
4
|
+
* Runs in human-readable stdout mode (not MCP stdio).
|
|
5
|
+
*
|
|
6
|
+
* @module agent-pool/cli
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { loadConfig } from './runner/config.js';
|
|
14
|
+
|
|
15
|
+
const PACKAGE_JSON = JSON.parse(
|
|
16
|
+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const GEMINI_NPM_PACKAGE = '@google/gemini-cli';
|
|
20
|
+
const MIN_NODE_VERSION = 20;
|
|
21
|
+
|
|
22
|
+
// ─── Colors (ANSI) ──────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const color = {
|
|
25
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
26
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
27
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
28
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
29
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
30
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ok = (msg) => console.log(` ${color.green('✅')} ${msg}`);
|
|
34
|
+
const fail = (msg) => console.log(` ${color.red('❌')} ${msg}`);
|
|
35
|
+
const warn = (msg) => console.log(` ${color.yellow('⚠️')} ${msg}`);
|
|
36
|
+
|
|
37
|
+
// ─── Check command ──────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run comprehensive diagnostics (doctor mode).
|
|
41
|
+
* Checks prerequisites, runners, and config.
|
|
42
|
+
*/
|
|
43
|
+
export function runCheck() {
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(color.bold('🔍 Agent Pool Doctor'));
|
|
46
|
+
console.log(color.dim(` v${PACKAGE_JSON.version}`));
|
|
47
|
+
console.log('');
|
|
48
|
+
|
|
49
|
+
let issues = 0;
|
|
50
|
+
|
|
51
|
+
// — Node.js version
|
|
52
|
+
console.log(color.cyan('Prerequisites:'));
|
|
53
|
+
const nodeVersion = parseInt(process.versions.node);
|
|
54
|
+
if (nodeVersion >= MIN_NODE_VERSION) {
|
|
55
|
+
ok(`Node.js v${process.versions.node} ${color.dim(`(>= ${MIN_NODE_VERSION})`)}`);
|
|
56
|
+
} else {
|
|
57
|
+
fail(`Node.js v${process.versions.node} — requires >= ${MIN_NODE_VERSION}`);
|
|
58
|
+
issues++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// — Gemini CLI binary
|
|
62
|
+
let geminiPath = null;
|
|
63
|
+
try {
|
|
64
|
+
geminiPath = execFileSync('which', ['gemini'], { encoding: 'utf-8' }).trim();
|
|
65
|
+
} catch {
|
|
66
|
+
// not found
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (geminiPath) {
|
|
70
|
+
let geminiVersion = 'unknown';
|
|
71
|
+
try {
|
|
72
|
+
geminiVersion = execFileSync('gemini', ['--version'], {
|
|
73
|
+
encoding: 'utf-8',
|
|
74
|
+
timeout: 5000,
|
|
75
|
+
}).trim();
|
|
76
|
+
} catch {
|
|
77
|
+
// version check failed
|
|
78
|
+
}
|
|
79
|
+
ok(`Gemini CLI v${geminiVersion} ${color.dim(geminiPath)}`);
|
|
80
|
+
} else {
|
|
81
|
+
fail(`Gemini CLI — not found in PATH`);
|
|
82
|
+
console.log(color.dim(` Install: npm install -g ${GEMINI_NPM_PACKAGE}`));
|
|
83
|
+
console.log(color.dim(` Then run: gemini (to authenticate)`));
|
|
84
|
+
issues++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// — Runners
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(color.cyan('Runners:'));
|
|
90
|
+
const config = loadConfig();
|
|
91
|
+
const configSource = findConfigPath();
|
|
92
|
+
|
|
93
|
+
for (const runner of config.runners) {
|
|
94
|
+
if (runner.type === 'local') {
|
|
95
|
+
if (geminiPath) {
|
|
96
|
+
ok(`${color.bold(runner.id)} — local ${runner.id === config.defaultRunner ? color.dim('(default)') : ''}`);
|
|
97
|
+
} else {
|
|
98
|
+
fail(`${color.bold(runner.id)} — local (gemini not found)`);
|
|
99
|
+
issues++;
|
|
100
|
+
}
|
|
101
|
+
} else if (runner.type === 'ssh') {
|
|
102
|
+
const sshResult = testSshRunner(runner);
|
|
103
|
+
if (sshResult.ok) {
|
|
104
|
+
ok(`${color.bold(runner.id)} — ssh:${runner.host} ${color.dim(`gemini v${sshResult.version}`)} ${runner.id === config.defaultRunner ? color.dim('(default)') : ''}`);
|
|
105
|
+
} else {
|
|
106
|
+
fail(`${color.bold(runner.id)} — ssh:${runner.host} — ${sshResult.error}`);
|
|
107
|
+
issues++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// — Config source
|
|
113
|
+
console.log('');
|
|
114
|
+
console.log(color.cyan('Config:'));
|
|
115
|
+
if (configSource) {
|
|
116
|
+
ok(configSource);
|
|
117
|
+
} else {
|
|
118
|
+
console.log(` ${color.dim(' No config file (using defaults)')}`);
|
|
119
|
+
console.log(color.dim(` Create one with: npx agent-pool-mcp --init`));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// — MCP config snippet
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log(color.cyan('MCP config snippet (copy to your IDE):'));
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(color.dim(' {'));
|
|
127
|
+
console.log(color.dim(' "mcpServers": {'));
|
|
128
|
+
console.log(color.dim(' "agent-pool": {'));
|
|
129
|
+
console.log(color.dim(' "command": "npx",'));
|
|
130
|
+
console.log(color.dim(' "args": ["-y", "agent-pool-mcp"]'));
|
|
131
|
+
console.log(color.dim(' }'));
|
|
132
|
+
console.log(color.dim(' }'));
|
|
133
|
+
console.log(color.dim(' }'));
|
|
134
|
+
|
|
135
|
+
// — Summary
|
|
136
|
+
console.log('');
|
|
137
|
+
if (issues === 0) {
|
|
138
|
+
console.log(color.green(color.bold('All checks passed! ✨')));
|
|
139
|
+
} else {
|
|
140
|
+
console.log(color.yellow(`${issues} issue(s) found. Fix them and run --check again.`));
|
|
141
|
+
}
|
|
142
|
+
console.log('');
|
|
143
|
+
|
|
144
|
+
return issues;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Init command ───────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate a template agent-pool.config.json in current directory.
|
|
151
|
+
*/
|
|
152
|
+
export function runInit() {
|
|
153
|
+
const targetPath = path.join(process.cwd(), 'agent-pool.config.json');
|
|
154
|
+
|
|
155
|
+
if (fs.existsSync(targetPath)) {
|
|
156
|
+
console.log(color.yellow(`⚠️ ${targetPath} already exists. Not overwriting.`));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const template = {
|
|
161
|
+
runners: [
|
|
162
|
+
{ id: 'local', type: 'local' },
|
|
163
|
+
{ id: 'remote', type: 'ssh', host: 'your-server', cwd: '/home/dev/project' },
|
|
164
|
+
],
|
|
165
|
+
defaultRunner: 'local',
|
|
166
|
+
defaultModel: 'gemini-3.1-pro-preview',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
fs.writeFileSync(targetPath, JSON.stringify(template, null, 2) + '\n');
|
|
170
|
+
console.log(color.green(`✅ Created ${targetPath}`));
|
|
171
|
+
console.log(color.dim(' Edit the file and update SSH host/cwd for remote runners.'));
|
|
172
|
+
console.log(color.dim(' Remove the "remote" runner if you only need local execution.'));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Version command ────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export function printVersion() {
|
|
178
|
+
console.log(`agent-pool-mcp v${PACKAGE_JSON.version}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Startup validation (fast, for MCP mode) ────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Quick prerequisite check before MCP server starts.
|
|
185
|
+
* Only checks gemini binary existence (< 50ms).
|
|
186
|
+
* Outputs to stderr (MCP protocol compatibility).
|
|
187
|
+
*
|
|
188
|
+
* @returns {boolean} true if prerequisites met
|
|
189
|
+
*/
|
|
190
|
+
export function validateStartup() {
|
|
191
|
+
try {
|
|
192
|
+
execFileSync('which', ['gemini'], { encoding: 'utf-8', timeout: 2000 });
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
console.error('[agent-pool] ❌ Gemini CLI not found in PATH.');
|
|
196
|
+
console.error(`[agent-pool] Install: npm install -g ${GEMINI_NPM_PACKAGE}`);
|
|
197
|
+
console.error('[agent-pool] Then run: gemini (to authenticate)');
|
|
198
|
+
console.error('[agent-pool] Docs: https://github.com/google-gemini/gemini-cli');
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Test if an SSH runner can connect and run gemini.
|
|
207
|
+
*/
|
|
208
|
+
function testSshRunner(runner) {
|
|
209
|
+
try {
|
|
210
|
+
const output = execSync(
|
|
211
|
+
`ssh -o ConnectTimeout=5 -o BatchMode=yes ${runner.host} 'gemini --version' 2>/dev/null`,
|
|
212
|
+
{ encoding: 'utf-8', timeout: 10000 }
|
|
213
|
+
).trim();
|
|
214
|
+
return { ok: true, version: output || 'unknown' };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const msg = err.message || '';
|
|
217
|
+
if (msg.includes('timed out') || msg.includes('ETIMEDOUT')) {
|
|
218
|
+
return { ok: false, error: 'connection timeout' };
|
|
219
|
+
}
|
|
220
|
+
if (msg.includes('Permission denied') || msg.includes('publickey')) {
|
|
221
|
+
return { ok: false, error: 'auth failed (check SSH keys)' };
|
|
222
|
+
}
|
|
223
|
+
if (msg.includes('Could not resolve')) {
|
|
224
|
+
return { ok: false, error: 'host not found' };
|
|
225
|
+
}
|
|
226
|
+
return { ok: false, error: 'connection failed' };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Find which config file is actually loaded.
|
|
232
|
+
*/
|
|
233
|
+
function findConfigPath() {
|
|
234
|
+
const candidates = [
|
|
235
|
+
path.join(process.cwd(), 'agent-pool.config.json'),
|
|
236
|
+
path.join(homedir(), '.config', 'agent-pool', 'config.json'),
|
|
237
|
+
];
|
|
238
|
+
return candidates.find((f) => fs.existsSync(f)) ?? null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── CLI Router ──────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse argv and run CLI command.
|
|
245
|
+
* Returns true if a CLI command was handled (don't start MCP server).
|
|
246
|
+
*/
|
|
247
|
+
export function handleCli(argv) {
|
|
248
|
+
const args = argv.slice(2);
|
|
249
|
+
|
|
250
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
251
|
+
printVersion();
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (args.includes('--check') || args.includes('--doctor')) {
|
|
256
|
+
const issues = runCheck();
|
|
257
|
+
process.exit(issues > 0 ? 1 : 0);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (args.includes('--init')) {
|
|
262
|
+
runInit();
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
267
|
+
printHelp();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function printHelp() {
|
|
275
|
+
console.log(`
|
|
276
|
+
${color.bold('agent-pool-mcp')} v${PACKAGE_JSON.version}
|
|
277
|
+
${color.dim('MCP server for multi-agent orchestration via Gemini CLI')}
|
|
278
|
+
|
|
279
|
+
${color.cyan('Usage:')}
|
|
280
|
+
agent-pool-mcp Start MCP server (stdio transport)
|
|
281
|
+
agent-pool-mcp --check Run diagnostics (doctor mode)
|
|
282
|
+
agent-pool-mcp --init Create template config file
|
|
283
|
+
agent-pool-mcp --version Show version
|
|
284
|
+
agent-pool-mcp --help Show this help
|
|
285
|
+
|
|
286
|
+
${color.cyan('MCP config (paste into your IDE):')}
|
|
287
|
+
{
|
|
288
|
+
"mcpServers": {
|
|
289
|
+
"agent-pool": {
|
|
290
|
+
"command": "npx",
|
|
291
|
+
"args": ["-y", "agent-pool-mcp"]
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
${color.cyan('Docs:')} https://github.com/rnd-pro/agent-pool-mcp
|
|
297
|
+
`);
|
|
298
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner configuration — loads and resolves runner definitions.
|
|
3
|
+
*
|
|
4
|
+
* Supports local and SSH runners. SSH config (keys, ports, jump hosts)
|
|
5
|
+
* is handled by ~/.ssh/config — we only store host and remote cwd.
|
|
6
|
+
*
|
|
7
|
+
* @module agent-pool/runner/config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} RunnerDef
|
|
16
|
+
* @property {string} id - Runner identifier
|
|
17
|
+
* @property {'local'|'ssh'} type - Runner type
|
|
18
|
+
* @property {string} [host] - SSH host (for type=ssh)
|
|
19
|
+
* @property {string} [cwd] - Remote working directory (for type=ssh)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Default model for all delegated tasks */
|
|
23
|
+
const DEFAULT_MODEL = 'gemini-3.1-pro-preview';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CONFIG = {
|
|
26
|
+
runners: [{ id: 'local', type: 'local' }],
|
|
27
|
+
defaultRunner: 'local',
|
|
28
|
+
defaultModel: DEFAULT_MODEL,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** @type {{runners: RunnerDef[], defaultRunner: string}|null} */
|
|
32
|
+
let cachedConfig = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load runner config from agent-pool.config.json.
|
|
36
|
+
* Search order: CWD, ~/.config/agent-pool/, fallback to default (local only).
|
|
37
|
+
*
|
|
38
|
+
* @returns {{runners: RunnerDef[], defaultRunner: string}}
|
|
39
|
+
*/
|
|
40
|
+
export function loadConfig() {
|
|
41
|
+
if (cachedConfig) return cachedConfig;
|
|
42
|
+
|
|
43
|
+
const candidates = [
|
|
44
|
+
path.join(process.cwd(), 'agent-pool.config.json'),
|
|
45
|
+
path.join(homedir(), '.config', 'agent-pool', 'config.json'),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const filePath of candidates) {
|
|
49
|
+
if (fs.existsSync(filePath)) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
cachedConfig = {
|
|
54
|
+
runners: parsed.runners ?? DEFAULT_CONFIG.runners,
|
|
55
|
+
defaultRunner: parsed.defaultRunner ?? 'local',
|
|
56
|
+
defaultModel: parsed.defaultModel ?? DEFAULT_MODEL,
|
|
57
|
+
};
|
|
58
|
+
console.error(`[agent-pool] Config loaded from ${filePath}`);
|
|
59
|
+
return cachedConfig;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`[agent-pool] Failed to parse ${filePath}: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
cachedConfig = DEFAULT_CONFIG;
|
|
67
|
+
return cachedConfig;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get a specific runner by ID.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} [runnerId] - Runner ID, defaults to defaultRunner
|
|
74
|
+
* @returns {RunnerDef}
|
|
75
|
+
*/
|
|
76
|
+
export function getRunner(runnerId) {
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
const id = runnerId ?? config.defaultRunner;
|
|
79
|
+
const runner = config.runners.find((r) => r.id === id);
|
|
80
|
+
if (!runner) {
|
|
81
|
+
console.error(`[agent-pool] Runner "${id}" not found, falling back to local`);
|
|
82
|
+
return { id: 'local', type: 'local' };
|
|
83
|
+
}
|
|
84
|
+
return runner;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Invalidate cached config (for testing or hot reload).
|
|
89
|
+
*/
|
|
90
|
+
export function resetConfig() {
|
|
91
|
+
cachedConfig = null;
|
|
92
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI runner — spawns Gemini CLI processes with streaming JSON output.
|
|
3
|
+
*
|
|
4
|
+
* Uses process-manager for PID tracking and group kill on timeout.
|
|
5
|
+
*
|
|
6
|
+
* @module agent-pool/runner/gemini-runner
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn, execFile } from 'node:child_process';
|
|
10
|
+
import { trackChild, killGroup, untrackChild } from './process-manager.js';
|
|
11
|
+
import { getRunner, loadConfig } from './config.js';
|
|
12
|
+
import { buildSshSpawn, parseRemotePid } from './ssh.js';
|
|
13
|
+
import { setTaskPid, updateTaskResult, pushTaskEvent } from '../tools/results.js';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TIMEOUT_SEC = 600;
|
|
16
|
+
const DEFAULT_APPROVAL_MODE = 'yolo';
|
|
17
|
+
|
|
18
|
+
export { DEFAULT_TIMEOUT_SEC, DEFAULT_APPROVAL_MODE };
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run Gemini CLI with stream-json format and collect events.
|
|
23
|
+
* Spawns with detached=true for proper group kill on timeout.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} options
|
|
26
|
+
* @param {string} options.prompt - Task prompt
|
|
27
|
+
* @param {string} [options.cwd] - Working directory
|
|
28
|
+
* @param {string} [options.model] - Model ID
|
|
29
|
+
* @param {string} [options.approvalMode] - Approval mode
|
|
30
|
+
* @param {number} [options.timeout] - Timeout in seconds
|
|
31
|
+
* @param {string} [options.sessionId] - Session to resume
|
|
32
|
+
* @param {string} [options.taskId] - Task ID for tracking
|
|
33
|
+
* @returns {Promise<object>} Collected events and final response
|
|
34
|
+
*/
|
|
35
|
+
export function runGeminiStreaming({ prompt, cwd, model, approvalMode, timeout, sessionId, taskId, runner: runnerId, policy, includeDirs }) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const runner = getRunner(runnerId);
|
|
38
|
+
const isRemote = runner.type === 'ssh';
|
|
39
|
+
const args = [];
|
|
40
|
+
|
|
41
|
+
if (sessionId) {
|
|
42
|
+
args.push('--resume', sessionId);
|
|
43
|
+
}
|
|
44
|
+
args.push('-p', prompt);
|
|
45
|
+
args.push(
|
|
46
|
+
'--output-format', 'stream-json',
|
|
47
|
+
'--approval-mode', approvalMode ?? DEFAULT_APPROVAL_MODE,
|
|
48
|
+
);
|
|
49
|
+
const effectiveModel = model || loadConfig().defaultModel;
|
|
50
|
+
if (effectiveModel) {
|
|
51
|
+
args.push('--model', effectiveModel);
|
|
52
|
+
}
|
|
53
|
+
if (policy) {
|
|
54
|
+
args.push('--policy', policy);
|
|
55
|
+
}
|
|
56
|
+
if (includeDirs?.length > 0) {
|
|
57
|
+
for (const dir of includeDirs) {
|
|
58
|
+
args.push('--include-directories', dir);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const timeoutMs = (timeout ?? DEFAULT_TIMEOUT_SEC) * 1000;
|
|
63
|
+
|
|
64
|
+
let spawnCmd, spawnArgs, spawnOpts;
|
|
65
|
+
|
|
66
|
+
if (isRemote) {
|
|
67
|
+
const ssh = buildSshSpawn(runner, args, cwd ?? process.cwd());
|
|
68
|
+
spawnCmd = ssh.command;
|
|
69
|
+
spawnArgs = ssh.args;
|
|
70
|
+
spawnOpts = { stdio: ['pipe', 'pipe', 'pipe'], detached: true };
|
|
71
|
+
} else {
|
|
72
|
+
spawnCmd = 'gemini';
|
|
73
|
+
spawnArgs = args;
|
|
74
|
+
const currentDepth = parseInt(process.env.AGENT_POOL_DEPTH ?? '0');
|
|
75
|
+
spawnOpts = {
|
|
76
|
+
cwd: cwd ?? process.cwd(),
|
|
77
|
+
env: {
|
|
78
|
+
...process.env,
|
|
79
|
+
TERM: 'dumb',
|
|
80
|
+
CI: '1',
|
|
81
|
+
AGENT_POOL_DEPTH: String(currentDepth + 1),
|
|
82
|
+
},
|
|
83
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
84
|
+
detached: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const child = spawn(spawnCmd, spawnArgs, spawnOpts);
|
|
89
|
+
|
|
90
|
+
trackChild(child.pid, taskId ?? 'streaming', `gemini-${isRemote ? 'ssh' : 'local'}`);
|
|
91
|
+
if (taskId) setTaskPid(taskId, child.pid);
|
|
92
|
+
|
|
93
|
+
const events = [];
|
|
94
|
+
let stderrData = '';
|
|
95
|
+
let buffer = '';
|
|
96
|
+
let timeoutHandle;
|
|
97
|
+
let remotePid = null;
|
|
98
|
+
let resolved = false;
|
|
99
|
+
|
|
100
|
+
if (timeoutMs > 0) {
|
|
101
|
+
timeoutHandle = setTimeout(() => {
|
|
102
|
+
// Soft timeout: resolve with partial data, let process continue in background
|
|
103
|
+
clearTimeout(timeoutHandle);
|
|
104
|
+
timeoutHandle = null;
|
|
105
|
+
resolved = true;
|
|
106
|
+
|
|
107
|
+
const messages = events.filter((e) => e.type === 'message');
|
|
108
|
+
const toolUses = events.filter((e) => e.type === 'tool_use');
|
|
109
|
+
const responseText = messages
|
|
110
|
+
.filter((m) => m.role === 'assistant')
|
|
111
|
+
.map((m) => m.content ?? m.text ?? '')
|
|
112
|
+
.join('\n');
|
|
113
|
+
|
|
114
|
+
resolve({
|
|
115
|
+
sessionId: events.find((e) => e.type === 'init')?.session_id ?? null,
|
|
116
|
+
response: responseText || '⏳ Agent is still working (soft timeout reached). Partial results returned.',
|
|
117
|
+
stats: null,
|
|
118
|
+
toolCalls: toolUses.map((t) => ({
|
|
119
|
+
name: t.tool_name ?? t.name ?? 'unknown',
|
|
120
|
+
args: t.parameters ?? t.arguments,
|
|
121
|
+
})),
|
|
122
|
+
toolResults: [],
|
|
123
|
+
errors: [],
|
|
124
|
+
exitCode: null,
|
|
125
|
+
totalEvents: events.length,
|
|
126
|
+
softTimeout: true,
|
|
127
|
+
timeoutSeconds: timeout ?? DEFAULT_TIMEOUT_SEC,
|
|
128
|
+
});
|
|
129
|
+
// Process continues running — will be cleaned up on natural exit
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
child.stdout.on('data', (chunk) => {
|
|
134
|
+
buffer += chunk.toString();
|
|
135
|
+
const lines = buffer.split('\n');
|
|
136
|
+
buffer = lines.pop();
|
|
137
|
+
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
const trimmed = line.trim();
|
|
140
|
+
if (!trimmed) continue;
|
|
141
|
+
|
|
142
|
+
// Parse remote PID from SSH wrapper
|
|
143
|
+
if (isRemote && !remotePid) {
|
|
144
|
+
const pid = parseRemotePid(trimmed);
|
|
145
|
+
if (pid) {
|
|
146
|
+
remotePid = pid;
|
|
147
|
+
continue; // Don't parse PID line as JSON
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(trimmed);
|
|
153
|
+
events.push(parsed);
|
|
154
|
+
if (taskId) pushTaskEvent(taskId, parsed);
|
|
155
|
+
} catch {
|
|
156
|
+
// Skip non-JSON lines
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
child.stderr.on('data', (chunk) => {
|
|
162
|
+
stderrData += chunk.toString();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
child.on('close', (code) => {
|
|
166
|
+
clearTimeout(timeoutHandle);
|
|
167
|
+
untrackChild(child.pid);
|
|
168
|
+
|
|
169
|
+
// If already resolved via soft timeout, update with final complete result
|
|
170
|
+
if (resolved) {
|
|
171
|
+
if (buffer.trim()) {
|
|
172
|
+
try { events.push(JSON.parse(buffer.trim())); } catch { /* ignore */ }
|
|
173
|
+
}
|
|
174
|
+
const messages = events.filter((e) => e.type === 'message');
|
|
175
|
+
const toolUses = events.filter((e) => e.type === 'tool_use');
|
|
176
|
+
const toolResults = events.filter((e) => e.type === 'tool_result');
|
|
177
|
+
const resultEvent = events.find((e) => e.type === 'result');
|
|
178
|
+
const errors = events.filter((e) => e.type === 'error');
|
|
179
|
+
const responseText = messages.filter((m) => m.role === 'assistant').map((m) => m.content ?? m.text ?? '').join('\n');
|
|
180
|
+
if (taskId) {
|
|
181
|
+
updateTaskResult(taskId, {
|
|
182
|
+
sessionId: events.find((e) => e.type === 'init')?.session_id ?? null,
|
|
183
|
+
response: resultEvent?.response ?? responseText,
|
|
184
|
+
stats: resultEvent?.stats ?? null,
|
|
185
|
+
toolCalls: toolUses.map((t) => ({ name: t.tool_name ?? t.name ?? 'unknown', args: t.parameters ?? t.arguments })),
|
|
186
|
+
toolResults: toolResults.map((t) => ({ name: t.tool_name ?? t.tool_id ?? t.name ?? 'unknown', output: t.output ? (typeof t.output === 'string' ? t.output.substring(0, 500) : JSON.stringify(t.output)?.substring(0, 500)) : t.status ?? '' })),
|
|
187
|
+
errors: errors.map((e) => e.message ?? e.error ?? JSON.stringify(e)),
|
|
188
|
+
exitCode: code,
|
|
189
|
+
totalEvents: events.length,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Process remaining buffer
|
|
196
|
+
if (buffer.trim()) {
|
|
197
|
+
try { events.push(JSON.parse(buffer.trim())); } catch { /* ignore */ }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const messages = events.filter((e) => e.type === 'message');
|
|
201
|
+
const toolUses = events.filter((e) => e.type === 'tool_use');
|
|
202
|
+
const toolResults = events.filter((e) => e.type === 'tool_result');
|
|
203
|
+
const resultEvent = events.find((e) => e.type === 'result');
|
|
204
|
+
const errors = events.filter((e) => e.type === 'error');
|
|
205
|
+
|
|
206
|
+
const responseText = messages
|
|
207
|
+
.filter((m) => m.role === 'assistant')
|
|
208
|
+
.map((m) => m.content ?? m.text ?? '')
|
|
209
|
+
.join('\n');
|
|
210
|
+
|
|
211
|
+
const initEvent = events.find((e) => e.type === 'init');
|
|
212
|
+
|
|
213
|
+
resolve({
|
|
214
|
+
sessionId: initEvent?.session_id ?? initEvent?.sessionId ?? null,
|
|
215
|
+
response: resultEvent?.response ?? responseText,
|
|
216
|
+
stats: resultEvent?.stats ?? null,
|
|
217
|
+
toolCalls: toolUses.map((t) => ({
|
|
218
|
+
name: t.tool_name ?? t.name ?? 'unknown',
|
|
219
|
+
args: t.parameters ?? t.arguments,
|
|
220
|
+
})),
|
|
221
|
+
toolResults: toolResults.map((t) => ({
|
|
222
|
+
name: t.tool_name ?? t.tool_id ?? t.name ?? 'unknown',
|
|
223
|
+
output: t.output
|
|
224
|
+
? (typeof t.output === 'string' ? t.output.substring(0, 500) : JSON.stringify(t.output)?.substring(0, 500))
|
|
225
|
+
: t.status ?? '',
|
|
226
|
+
})),
|
|
227
|
+
errors: errors.map((e) => e.message ?? e.error ?? JSON.stringify(e)),
|
|
228
|
+
exitCode: code,
|
|
229
|
+
totalEvents: events.length,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
child.on('error', (err) => {
|
|
234
|
+
clearTimeout(timeoutHandle);
|
|
235
|
+
untrackChild(child.pid);
|
|
236
|
+
if (resolved) return;
|
|
237
|
+
reject(new Error(`Failed to spawn gemini: ${err.message}`));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
child.stdin.end();
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* List available Gemini CLI sessions for a project directory.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} cwd - Working directory
|
|
248
|
+
* @returns {Promise<Array<{index: number, preview: string, timeAgo: string, sessionId: string}>>}
|
|
249
|
+
*/
|
|
250
|
+
export function listGeminiSessions(cwd) {
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
execFile('gemini', ['--list-sessions'], {
|
|
253
|
+
cwd,
|
|
254
|
+
timeout: 10000,
|
|
255
|
+
env: { ...process.env, TERM: 'dumb', CI: '1' },
|
|
256
|
+
}, (error, stdout) => {
|
|
257
|
+
if (error) return resolve([]);
|
|
258
|
+
const sessions = [];
|
|
259
|
+
for (const line of stdout.split('\n')) {
|
|
260
|
+
const match = line.match(/^\s*(\d+)\.\s+(.+?)\s+\((.+?)\)\s+\[([a-f0-9-]+)\]/);
|
|
261
|
+
if (match) {
|
|
262
|
+
sessions.push({
|
|
263
|
+
index: parseInt(match[1]),
|
|
264
|
+
preview: match[2].trim(),
|
|
265
|
+
timeAgo: match[3],
|
|
266
|
+
sessionId: match[4],
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
resolve(sessions);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|