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/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
+ }