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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Process Manager — tracks spawned Gemini CLI processes and ensures cleanup.
3
+ *
4
+ * Fixes zombie process bug: spawns with detached=true, kills with
5
+ * process.kill(-pid) to terminate the entire process group.
6
+ *
7
+ * @module agent-pool/runner/process-manager
8
+ */
9
+
10
+ import { execSync } from 'node:child_process';
11
+
12
+ /** @type {Map<number, {taskId: string, startTime: number, label: string}>} */
13
+ const children = new Map();
14
+
15
+ /**
16
+ * Register a spawned child process for tracking.
17
+ *
18
+ * @param {number} pid - Child process PID
19
+ * @param {string} taskId - Associated task ID
20
+ * @param {string} [label] - Human-readable label
21
+ */
22
+ export function trackChild(pid, taskId, label = '') {
23
+ children.set(pid, { taskId, startTime: Date.now(), label });
24
+ }
25
+
26
+ /**
27
+ * Kill an entire process group by PID.
28
+ * Uses negative PID to kill the process group (only works with detached processes).
29
+ *
30
+ * @param {number} pid - Process PID
31
+ * @returns {boolean} Whether kill signal was sent
32
+ */
33
+ export function killGroup(pid) {
34
+ try {
35
+ process.kill(-pid, 'SIGTERM');
36
+ children.delete(pid);
37
+ return true;
38
+ } catch (err) {
39
+ // ESRCH = process not found (already dead)
40
+ if (err.code === 'ESRCH') {
41
+ children.delete(pid);
42
+ return false;
43
+ }
44
+ // EPERM = no permission — try simple kill
45
+ try {
46
+ process.kill(pid, 'SIGTERM');
47
+ children.delete(pid);
48
+ return true;
49
+ } catch {
50
+ children.delete(pid);
51
+ return false;
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Untrack a child process (called on normal exit).
58
+ *
59
+ * @param {number} pid
60
+ */
61
+ export function untrackChild(pid) {
62
+ children.delete(pid);
63
+ }
64
+
65
+ /**
66
+ * Kill all tracked child processes.
67
+ * Called on SIGTERM/SIGINT for graceful shutdown.
68
+ *
69
+ * @returns {number} Number of processes killed
70
+ */
71
+ export function killAll() {
72
+ let killed = 0;
73
+ for (const pid of children.keys()) {
74
+ if (killGroup(pid)) killed++;
75
+ }
76
+ return killed;
77
+ }
78
+
79
+ /**
80
+ * Get list of currently tracked processes.
81
+ *
82
+ * @returns {Array<{pid: number, taskId: string, startTime: number, label: string}>}
83
+ */
84
+ export function listChildren() {
85
+ return [...children.entries()].map(([pid, info]) => ({ pid, ...info }));
86
+ }
87
+
88
+ /**
89
+ * Get system-wide Gemini process load.
90
+ * Counts all `gemini` processes on the system, separating ours from external.
91
+ *
92
+ * @returns {{total: number, ours: number, external: number, warning: string|null}}
93
+ */
94
+ export function getSystemLoad() {
95
+ let total = 0;
96
+ try {
97
+ // Use the exact gemini binary path to avoid false positives
98
+ // (e.g. Chrome processes using .gemini/ profile directory)
99
+ const geminiPath = execSync('which gemini 2>/dev/null || true', { encoding: 'utf-8' }).trim();
100
+ if (geminiPath) {
101
+ const out = execSync(`pgrep -f "${geminiPath}" 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
102
+ if (out) {
103
+ total = out.split('\n').filter(Boolean).length;
104
+ }
105
+ }
106
+ } catch {
107
+ // pgrep not available or failed
108
+ }
109
+
110
+ const ours = children.size;
111
+ const external = Math.max(0, total - ours);
112
+
113
+ let warning = null;
114
+ if (external > 0) {
115
+ warning = `⚠️ System load: ${external} other Gemini process${external > 1 ? 'es' : ''} running — responses may be slower.`;
116
+ }
117
+
118
+ return { total, ours, external, warning };
119
+ }
120
+
121
+ // Cleanup on exit signals
122
+ process.on('SIGTERM', () => {
123
+ const count = killAll();
124
+ if (count > 0) {
125
+ console.error(`[agent-pool] SIGTERM: killed ${count} child process(es)`);
126
+ }
127
+ process.exit(0);
128
+ });
129
+
130
+ process.on('SIGINT', () => {
131
+ const count = killAll();
132
+ if (count > 0) {
133
+ console.error(`[agent-pool] SIGINT: killed ${count} child process(es)`);
134
+ }
135
+ process.exit(0);
136
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * SSH utilities — shell escaping and remote command building.
3
+ *
4
+ * @module agent-pool/runner/ssh
5
+ */
6
+
7
+ /**
8
+ * Escape a string for safe use in a remote shell command.
9
+ * Wraps in single quotes, escaping any embedded single quotes.
10
+ *
11
+ * @param {string} arg - Argument to escape
12
+ * @returns {string} Shell-safe argument
13
+ */
14
+ export function escapeShellArg(arg) {
15
+ // Replace single quotes: ' → '\''
16
+ // Then wrap entire string in single quotes
17
+ return `'${arg.replace(/'/g, "'\\''")}'`;
18
+ }
19
+
20
+ /**
21
+ * Build spawn arguments for SSH remote execution.
22
+ * Uses `echo REMOTE_PID:$$` to capture remote PID for cleanup.
23
+ *
24
+ * @param {object} runner - Runner config
25
+ * @param {string} runner.host - SSH host
26
+ * @param {string} [runner.cwd] - Remote working directory
27
+ * @param {string[]} geminiArgs - Gemini CLI arguments
28
+ * @param {string} localCwd - Local cwd (fallback for remote)
29
+ * @returns {{command: string, args: string[]}}
30
+ */
31
+ export function buildSshSpawn(runner, geminiArgs, localCwd) {
32
+ const remoteCwd = runner.cwd ?? localCwd;
33
+
34
+ // Build safe remote command with PID echo
35
+ const safeArgs = geminiArgs.map(escapeShellArg).join(' ');
36
+ const remoteCmd = `cd ${escapeShellArg(remoteCwd)} && echo "REMOTE_PID:$$" && exec gemini ${safeArgs}`;
37
+
38
+ return {
39
+ command: 'ssh',
40
+ args: [runner.host, remoteCmd],
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Parse REMOTE_PID from the first line of stdout.
46
+ *
47
+ * @param {string} line - stdout line
48
+ * @returns {number|null} Remote PID or null
49
+ */
50
+ export function parseRemotePid(line) {
51
+ const match = line.match(/^REMOTE_PID:(\d+)$/);
52
+ return match ? parseInt(match[1]) : null;
53
+ }
54
+
55
+ /**
56
+ * Kill a remote process group via SSH.
57
+ *
58
+ * @param {string} host - SSH host
59
+ * @param {number} remotePid - Remote PID to kill
60
+ * @returns {Promise<boolean>} Whether kill was attempted
61
+ */
62
+ export function killRemoteProcess(host, remotePid) {
63
+ return new Promise((resolve) => {
64
+ import('node:child_process').then(({ execFile }) => {
65
+ execFile('ssh', [host, `kill -TERM -${remotePid} 2>/dev/null; kill -TERM ${remotePid} 2>/dev/null`], {
66
+ timeout: 5000,
67
+ }, () => {
68
+ resolve(true);
69
+ });
70
+ });
71
+ });
72
+ }
package/src/server.js ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * MCP Server setup — tool registry and call handler.
3
+ * Connects tool definitions to their implementations.
4
+ *
5
+ * @module agent-pool/server
6
+ */
7
+
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import {
10
+ ListToolsRequestSchema,
11
+ CallToolRequestSchema,
12
+ } from '@modelcontextprotocol/sdk/types.js';
13
+ import { randomUUID } from 'node:crypto';
14
+
15
+ import { runGeminiStreaming, listGeminiSessions, DEFAULT_TIMEOUT_SEC, DEFAULT_APPROVAL_MODE } from './runner/gemini-runner.js';
16
+ import { getSystemLoad } from './runner/process-manager.js';
17
+ import { createTask, completeTask, failTask, formatTaskResult, getActiveTasks, cancelTask } from './tools/results.js';
18
+ import { listSkills, createSkill, deleteSkill, installSkill, provisionSkill } from './tools/skills.js';
19
+ import { consultPeer } from './tools/consult.js';
20
+
21
+ import { TOOL_DEFINITIONS } from './tool-definitions.js';
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { execFileSync } from 'node:child_process';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+
30
+ const defaultCwd = process.cwd();
31
+
32
+ // ─── Prerequisite check (cached at startup) ──────────────────
33
+
34
+ let geminiAvailable = null;
35
+
36
+ function checkGemini() {
37
+ if (geminiAvailable !== null) return geminiAvailable;
38
+ try {
39
+ execFileSync('which', ['gemini'], { encoding: 'utf-8', timeout: 2000 });
40
+ geminiAvailable = true;
41
+ } catch {
42
+ geminiAvailable = false;
43
+ }
44
+ return geminiAvailable;
45
+ }
46
+
47
+ const GEMINI_REQUIRED_ERROR = {
48
+ content: [{
49
+ type: 'text',
50
+ text: `❌ Gemini CLI is not installed or not in PATH.
51
+
52
+ **To fix:**
53
+ 1. Install: \`npm install -g @google/gemini-cli\`
54
+ 2. Authenticate: run \`gemini\` (opens browser for OAuth)
55
+ 3. Verify: \`gemini --version\`
56
+ 4. Restart your IDE to reload the MCP server.
57
+
58
+ Docs: https://github.com/google-gemini/gemini-cli`
59
+ }],
60
+ isError: true,
61
+ };
62
+
63
+ /** Tools that require Gemini CLI to be installed */
64
+ const GEMINI_TOOLS = new Set([
65
+ 'delegate_task', 'delegate_task_readonly', 'consult_peer', 'list_sessions',
66
+ ]);
67
+
68
+ // ─── Depth tracking (for nested orchestration) ──────────────
69
+
70
+ const CURRENT_DEPTH = parseInt(process.env.AGENT_POOL_DEPTH ?? '0');
71
+ const MAX_DEPTH = process.env.AGENT_POOL_MAX_DEPTH
72
+ ? parseInt(process.env.AGENT_POOL_MAX_DEPTH)
73
+ : null; // null = no limit (disabled by default)
74
+
75
+ function isDepthExceeded() {
76
+ return MAX_DEPTH !== null && CURRENT_DEPTH >= MAX_DEPTH;
77
+ }
78
+
79
+ const DEPTH_EXCEEDED_ERROR = {
80
+ content: [{
81
+ type: 'text',
82
+ text: `⚠️ Orchestration depth limit reached (depth=${CURRENT_DEPTH}, max=${MAX_DEPTH}).
83
+
84
+ This agent-pool instance is running inside a nested Gemini CLI worker.
85
+ Delegation is disabled at this depth to prevent runaway process spawning.
86
+
87
+ Execute the task directly instead of delegating it.
88
+
89
+ To increase the limit, set AGENT_POOL_MAX_DEPTH to a higher value.`
90
+ }],
91
+ isError: true,
92
+ };
93
+
94
+ /**
95
+ * Create and configure the MCP server.
96
+ *
97
+ * @returns {Server}
98
+ */
99
+ export function createServer() {
100
+ // Check gemini once at server creation
101
+ checkGemini();
102
+
103
+ if (CURRENT_DEPTH > 0) {
104
+ console.error(`[agent-pool] Nested orchestration: depth=${CURRENT_DEPTH}${MAX_DEPTH !== null ? `, max=${MAX_DEPTH}` : ''}`);
105
+ }
106
+
107
+ const server = new Server(
108
+ { name: 'agent-pool', version: '1.0.0' },
109
+ { capabilities: { tools: {} } },
110
+ );
111
+
112
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
113
+ tools: TOOL_DEFINITIONS,
114
+ }));
115
+
116
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
117
+ const { name, arguments: args } = request.params;
118
+
119
+ // Guard: tools that need gemini
120
+ if (GEMINI_TOOLS.has(name)) {
121
+ if (!checkGemini()) return GEMINI_REQUIRED_ERROR;
122
+ if (isDepthExceeded()) return DEPTH_EXCEEDED_ERROR;
123
+ }
124
+
125
+ let response;
126
+ try {
127
+ switch (name) {
128
+ case 'delegate_task':
129
+ response = handleDelegateTask(args); break;
130
+ case 'delegate_task_readonly':
131
+ response = handleDelegateReadonly(args); break;
132
+ case 'get_task_result':
133
+ response = formatTaskResult(args.task_id); break;
134
+ case 'cancel_task':
135
+ response = cancelTask(args.task_id); break;
136
+ case 'consult_peer':
137
+ response = consultPeer(args, defaultCwd); break;
138
+ case 'list_sessions':
139
+ response = await handleListSessions(args); break;
140
+ case 'list_skills':
141
+ response = handleListSkills(args); break;
142
+ case 'create_skill':
143
+ response = handleCreateSkill(args); break;
144
+ case 'delete_skill':
145
+ response = handleDeleteSkill(args); break;
146
+ case 'install_skill':
147
+ response = handleInstallSkill(args); break;
148
+ default:
149
+ response = { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
150
+ }
151
+ } catch (error) {
152
+ response = { content: [{ type: 'text', text: `Gemini CLI Error: ${error.message}` }], isError: true };
153
+ }
154
+
155
+ // Append active tasks footer to every response
156
+ const footer = getActiveTasks();
157
+ if (footer && response.content?.[0]?.text) {
158
+ response.content[0].text += footer;
159
+ }
160
+ return response;
161
+ });
162
+
163
+ return server;
164
+ }
165
+
166
+ // ─── Tool Handlers ──────────────────────────────────────────────────
167
+
168
+ /**
169
+ * Shared handler for delegate_task and delegate_task_readonly.
170
+ *
171
+ * @param {object} args - Tool arguments
172
+ * @param {object} defaults - Override defaults for the mode
173
+ * @param {string} defaults.approvalMode - Approval mode
174
+ * @param {string} defaults.emoji - Status emoji
175
+ * @param {string} defaults.label - Status label
176
+ * @returns {{content: Array<{type: string, text: string}>}}
177
+ */
178
+ function handleDelegate(args, { approvalMode, emoji, label }) {
179
+ const taskId = randomUUID();
180
+ const cwd = args.cwd ?? defaultCwd;
181
+ let prompt = args.prompt;
182
+
183
+ // Hybrid skill activation: provision to project, then instruct native activation
184
+ if (args.skill) {
185
+ const provisioned = provisionSkill(cwd, args.skill);
186
+ if (provisioned) {
187
+ prompt = `IMPORTANT: Before starting the task, activate the skill "${provisioned.name}" using the activate_skill tool. Then proceed with the task.\n\n${prompt}`;
188
+ } else {
189
+ prompt = `NOTE: Skill '${args.skill}' was requested but not found in any tier (project, global, built-in). Proceed with the task.\n\n${prompt}`;
190
+ }
191
+ }
192
+
193
+ // Resolve policy path (built-in templates or absolute path)
194
+ let policyPath = args.policy ?? null;
195
+ if (policyPath && !path.isAbsolute(policyPath)) {
196
+ const policiesDir = path.resolve(__dirname, '..', 'policies');
197
+ let builtinPolicy = path.resolve(policiesDir, policyPath);
198
+ if (!fs.existsSync(builtinPolicy) && fs.existsSync(builtinPolicy + '.yaml')) {
199
+ builtinPolicy = builtinPolicy + '.yaml';
200
+ }
201
+ // Path traversal protection: ensure resolved path stays within policies/
202
+ if (builtinPolicy.startsWith(policiesDir + path.sep) && fs.existsSync(builtinPolicy)) {
203
+ policyPath = builtinPolicy;
204
+ } else {
205
+ policyPath = null; // Invalid or traversal attempt — ignore
206
+ }
207
+ }
208
+
209
+ // Inject role awareness into the agent's prompt
210
+ const roleDescriptions = {
211
+ yolo: 'FULL ACCESS — you can read/write files, run shell commands, and make any changes.',
212
+ auto_edit: 'AUTO-EDIT — you can read files and edit code, but shell commands require approval.',
213
+ plan: 'READ-ONLY — you can only read files and analyze code. You CANNOT write files or run destructive commands.',
214
+ };
215
+ const resolvedMode = args.approval_mode ?? approvalMode;
216
+ const modeNotice = roleDescriptions[resolvedMode] ?? `Mode: ${resolvedMode}`;
217
+
218
+ // Build workspace scope awareness — tell the agent its sandbox boundaries upfront
219
+ const workspaceDirs = [cwd];
220
+ if (args.include_dirs?.length > 0) {
221
+ workspaceDirs.push(...args.include_dirs);
222
+ }
223
+ const scopeNotice = `[Workspace Scope] You have access to these directories:\n${workspaceDirs.map((d) => ` - ${d}`).join('\n')}\nIf you need files outside these paths, use shell commands (cat, find, ls) instead of file tools. Do NOT attempt list_directory or read_file on paths outside your workspace — they will be rejected by the sandbox.`;
224
+
225
+ prompt = `[Agent Mode: ${resolvedMode.toUpperCase()}] ${modeNotice}\n\n${scopeNotice}\n\n${prompt}`;
226
+
227
+ const taskOpts = {
228
+ prompt,
229
+ cwd,
230
+ model: args.model,
231
+ approvalMode: resolvedMode,
232
+ timeout: args.timeout ?? DEFAULT_TIMEOUT_SEC,
233
+ sessionId: args.session_id,
234
+ taskId,
235
+ runner: args.runner,
236
+ policy: policyPath,
237
+ includeDirs: args.include_dirs,
238
+ };
239
+
240
+ createTask(taskId, args.prompt, args.on_wait_hint, resolvedMode);
241
+
242
+ runGeminiStreaming(taskOpts)
243
+ .then((result) => completeTask(taskId, result))
244
+ .catch((err) => failTask(taskId, err.message));
245
+
246
+ const mode = args.approval_mode ?? approvalMode;
247
+ const runnerInfo = args.runner ? `\n- **Runner**: ${args.runner}` : '';
248
+ const skillInfo = args.skill ? `\n- **Skill**: ${args.skill}` : '';
249
+ const policyInfo = policyPath ? `\n- **Policy**: ${policyPath}` : '';
250
+
251
+ // System load awareness
252
+ const load = getSystemLoad();
253
+ const loadInfo = load.warning ? `\n\n${load.warning}` : '';
254
+
255
+ return {
256
+ content: [{
257
+ type: 'text',
258
+ text: `${emoji} ${label}\n\n- **Task ID**: \`${taskId}\`\n- **Mode**: ${mode}${runnerInfo}${skillInfo}${policyInfo}\n- **Prompt**: ${args.prompt.substring(0, 100)}...${loadInfo}\n\nUse \`get_task_result\` with this task_id to check status.`,
259
+ }],
260
+ };
261
+ }
262
+
263
+ function handleDelegateTask(args) {
264
+ return handleDelegate(args, {
265
+ approvalMode: DEFAULT_APPROVAL_MODE,
266
+ emoji: '🚀',
267
+ label: 'Task delegated.',
268
+ });
269
+ }
270
+
271
+ function handleDelegateReadonly(args) {
272
+ return handleDelegate(args, {
273
+ approvalMode: DEFAULT_APPROVAL_MODE,
274
+ emoji: '🔍',
275
+ label: 'Analysis task delegated (full access).',
276
+ });
277
+ }
278
+
279
+ /**
280
+ * @param {object} args
281
+ */
282
+ async function handleListSessions(args) {
283
+ const sessions = await listGeminiSessions(args.cwd ?? defaultCwd);
284
+ if (sessions.length === 0) {
285
+ return { content: [{ type: 'text', text: 'No sessions found for this project.' }] };
286
+ }
287
+ const lines = sessions.map(
288
+ (s) => `- **${s.index}**. ${s.preview} (${s.timeAgo}) \`${s.sessionId}\``,
289
+ );
290
+ return {
291
+ content: [{ type: 'text', text: `## Available Sessions (${sessions.length})\n\n${lines.join('\n')}` }],
292
+ };
293
+ }
294
+
295
+ /** @param {object} args */
296
+ function handleListSkills(args) {
297
+ const skills = listSkills(args.cwd ?? defaultCwd);
298
+ if (skills.length === 0) {
299
+ return { content: [{ type: 'text', text: 'No skills found. Use create_skill to create one.' }] };
300
+ }
301
+ const lines = skills.map(
302
+ (s) => `- **${s.name}** — ${s.description} (\`${s.fileName}\`) [${s.tier}]`,
303
+ );
304
+ return {
305
+ content: [{ type: 'text', text: `## Available Skills (${skills.length})\n\n${lines.join('\n')}` }],
306
+ };
307
+ }
308
+
309
+ /** @param {object} args */
310
+ function handleCreateSkill(args) {
311
+ const scope = args.scope ?? 'project';
312
+ const filePath = createSkill(args.cwd ?? defaultCwd, args.skill_name, args.description, args.instructions, scope);
313
+ return {
314
+ content: [{
315
+ type: 'text',
316
+ text: `✅ Skill created [${scope}]: \`${args.skill_name}\`\nPath: \`${filePath}\`\n\nUse with delegate_task: \`skill: "${args.skill_name}"\``,
317
+ }],
318
+ };
319
+ }
320
+
321
+ /** @param {object} args */
322
+ function handleDeleteSkill(args) {
323
+ const scope = args.scope ?? 'project';
324
+ const deleted = deleteSkill(args.cwd ?? defaultCwd, args.skill_name, scope);
325
+ return {
326
+ content: [{
327
+ type: 'text',
328
+ text: deleted ? `✅ Skill deleted [${scope}]: \`${args.skill_name}\`` : `❌ Skill not found [${scope}]: \`${args.skill_name}\``,
329
+ }],
330
+ };
331
+ }
332
+
333
+ /** @param {object} args */
334
+ function handleInstallSkill(args) {
335
+ const result = installSkill(args.cwd ?? defaultCwd, args.skill_name);
336
+ if (!result) {
337
+ return {
338
+ content: [{
339
+ type: 'text',
340
+ text: `❌ Skill \`${args.skill_name}\` not found in global or built-in tiers. Use \`list_skills\` to see available skills.`,
341
+ }],
342
+ };
343
+ }
344
+ return {
345
+ content: [{
346
+ type: 'text',
347
+ text: `✅ Skill installed into project:\n- **From**: \`${result.from}\` [${result.tier}]\n- **To**: \`${result.to}\`\n\nThe skill is now a local copy — you can customize it for this project.`,
348
+ }],
349
+ };
350
+ }