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,176 @@
1
+ /**
2
+ * MCP tool definitions — schema for all available tools.
3
+ * Separated from server.js for clarity.
4
+ *
5
+ * @module agent-pool/tool-definitions
6
+ */
7
+
8
+ export const TOOL_DEFINITIONS = [
9
+ {
10
+ name: 'delegate_task',
11
+ description: [
12
+ 'Delegate a coding task to a Gemini CLI agent running in headless mode.',
13
+ 'The agent is sandboxed to `cwd` directory only. Use `include_dirs` to grant access to additional directories.',
14
+ 'Use this for parallel work: code review, testing, refactoring, analysis, or any dev task.',
15
+ '',
16
+ 'Returns a task_id immediately (non-blocking). Use get_task_result to check status and retrieve the result.',
17
+ '',
18
+ 'IMPORTANT: Gemini CLI cold start takes ~15-20s before the agent begins working. Set timeout to at least 60s for simple tasks, 300s+ for complex analysis.',
19
+ 'WORKSPACE: The agent can only use file tools within `cwd` and `include_dirs`. For other paths it can use shell commands (cat, find, ls).',
20
+ ].join('\n'),
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ prompt: { type: 'string', description: 'The task description for the Gemini agent. Be specific and detailed.' },
25
+ cwd: { type: 'string', description: 'Working directory for the agent. Defaults to current working directory.' },
26
+ model: { type: 'string', description: 'Model to use. Options: gemini-3.1-pro-preview, gemini-3-flash-preview. Leave empty for Auto mode.' },
27
+ approval_mode: {
28
+ type: 'string',
29
+ enum: ['yolo', 'auto_edit', 'plan'],
30
+ description: 'Approval mode: yolo (auto-approve all), auto_edit (auto-approve edits only), plan (read-only). Default: yolo.',
31
+ },
32
+ timeout: { type: 'number', description: 'Timeout in seconds. Default: 600 (10 minutes).' },
33
+ session_id: { type: 'string', description: 'Resume an existing Gemini CLI session by its UUID. Use list_sessions to see available sessions.' },
34
+ skill: { type: 'string', description: 'Activate a Gemini CLI skill by name before executing the task. Use list_skills to see available skills.' },
35
+ runner: { type: 'string', description: 'Runner ID from agent-pool.config.json. Default: "local". Use SSH runners for remote execution.' },
36
+ policy: { type: 'string', description: 'Policy file for tool restrictions. Use built-in template name (e.g. "read-only", "safe-edit") or absolute path to .yaml policy file.' },
37
+ on_wait_hint: { type: 'string', description: 'Custom coaching message shown when polling for results. Guides the calling agent on what to do while waiting.' },
38
+ include_dirs: { type: 'array', items: { type: 'string' }, description: 'Additional directories to include in the agent workspace scope. By default the agent only has access to cwd. Use this to grant access to other project dirs, config dirs, etc.' },
39
+ },
40
+ required: ['prompt'],
41
+ },
42
+ },
43
+ {
44
+ name: 'delegate_task_readonly',
45
+ description: [
46
+ 'Delegate a read-only analysis task to Gemini CLI agent.',
47
+ 'The agent is sandboxed to `cwd` directory only. Use `include_dirs` to grant access to additional directories.',
48
+ 'It is semantically identical to delegate_task but signals that the task is primarily for analysis.',
49
+ 'Use this for code review, architecture analysis, finding bugs, writing reports, etc.',
50
+ '',
51
+ 'Returns a task_id immediately (non-blocking). Use get_task_result to check status and retrieve the result.',
52
+ '',
53
+ 'IMPORTANT: Gemini CLI cold start takes ~15-20s before the agent begins working. Set timeout to at least 60s for simple tasks, 300s+ for complex analysis.',
54
+ 'WORKSPACE: The agent can only use file tools within `cwd` and `include_dirs`. For other paths it can use shell commands (cat, find, ls).',
55
+ ].join('\n'),
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ prompt: { type: 'string', description: 'The analysis task for the Gemini agent.' },
60
+ cwd: { type: 'string', description: 'Working directory. Defaults to current working directory.' },
61
+ model: { type: 'string', description: 'Model to use. Leave empty for Auto.' },
62
+ timeout: { type: 'number', description: 'Timeout in seconds. Default: 600 (10 minutes).' },
63
+ session_id: { type: 'string', description: 'Resume an existing Gemini CLI session by its UUID. Use list_sessions to see available sessions.' },
64
+ runner: { type: 'string', description: 'Runner ID from agent-pool.config.json. Default: "local". Use SSH runners for remote execution.' },
65
+ on_wait_hint: { type: 'string', description: 'Custom coaching message shown when polling for results.' },
66
+ include_dirs: { type: 'array', items: { type: 'string' }, description: 'Additional directories to include in the agent workspace scope. By default the agent only has access to cwd.' },
67
+ },
68
+ required: ['prompt'],
69
+ },
70
+ },
71
+ {
72
+ name: 'consult_peer',
73
+ description: [
74
+ 'Consult a Gemini peer agent for architectural/technical consensus.',
75
+ 'Use during PLANNING phase to validate proposals before implementation.',
76
+ 'Supports iterative rounds: send proposal, get feedback, revise, resend until AGREE.',
77
+ 'The peer responds with a structured verdict: AGREE, SUGGEST_CHANGES, or DISAGREE.',
78
+ '',
79
+ 'Returns a task_id immediately (non-blocking). Use get_task_result to check the verdict.',
80
+ 'The peer runs without a timeout — it will work until done. Progress is visible via get_task_result.',
81
+ ].join('\n'),
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ context: { type: 'string', description: 'Project context: what are we working on, constraints, requirements.' },
86
+ proposal: { type: 'string', description: 'Your technical proposal or architectural decision to review.' },
87
+ previous_rounds: { type: 'string', description: 'Summary of previous discussion rounds (if iterating toward consensus).' },
88
+ cwd: { type: 'string', description: 'Working directory for file access. Defaults to current working directory.' },
89
+ model: { type: 'string', description: 'Model to use. Default: Auto.' },
90
+ },
91
+ required: ['context', 'proposal'],
92
+ },
93
+ },
94
+ {
95
+ name: 'get_task_result',
96
+ description: 'Check the status and result of a background task started with delegate_task, delegate_task_readonly, or consult_peer. Returns status: running, done, or error.',
97
+ inputSchema: {
98
+ type: 'object',
99
+ properties: {
100
+ task_id: { type: 'string', description: 'Task ID returned by delegate_task, delegate_task_readonly, or consult_peer.' },
101
+ },
102
+ required: ['task_id'],
103
+ },
104
+ },
105
+ {
106
+ name: 'cancel_task',
107
+ description: 'Cancel a running task and kill its process. Use when a task is stuck or no longer needed.',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ task_id: { type: 'string', description: 'Task ID to cancel.' },
112
+ },
113
+ required: ['task_id'],
114
+ },
115
+ },
116
+ {
117
+ name: 'list_sessions',
118
+ description: 'List available Gemini CLI sessions for a project directory. Returns session IDs, previews, and age. Use session_id with delegate_task to resume.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ cwd: { type: 'string', description: 'Project directory to list sessions for. Defaults to current working directory.' },
123
+ },
124
+ },
125
+ },
126
+ {
127
+ name: 'list_skills',
128
+ description: 'List available Gemini CLI skills from all tiers: project (.gemini/skills/), user-global (~/.gemini/skills/), and built-in (shipped with agent-pool). Shows tier label for each skill.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ cwd: { type: 'string', description: 'Project directory. Defaults to current working directory.' },
133
+ },
134
+ },
135
+ },
136
+ {
137
+ name: 'create_skill',
138
+ description: 'Create or update a Gemini CLI skill. Writes a .md file with YAML frontmatter. Use scope to control where: "project" (default) or "global" (~/.gemini/skills/).',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ skill_name: { type: 'string', description: 'Skill name (used as filename, e.g. "code-reviewer").' },
143
+ description: { type: 'string', description: 'Short description of what the skill does.' },
144
+ instructions: { type: 'string', description: 'Full markdown instructions for the skill. Define the agent role, rules, and output format.' },
145
+ scope: { type: 'string', enum: ['project', 'global'], description: 'Where to save: "project" (default, .gemini/skills/) or "global" (~/.gemini/skills/).' },
146
+ cwd: { type: 'string', description: 'Project directory. Defaults to current working directory.' },
147
+ },
148
+ required: ['skill_name', 'description', 'instructions'],
149
+ },
150
+ },
151
+ {
152
+ name: 'delete_skill',
153
+ description: 'Delete a Gemini CLI skill by name. Specify scope to target project or global tier.',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ skill_name: { type: 'string', description: 'Skill name to delete.' },
158
+ scope: { type: 'string', enum: ['project', 'global'], description: 'Tier to delete from: "project" (default) or "global".' },
159
+ cwd: { type: 'string', description: 'Project directory. Defaults to current working directory.' },
160
+ },
161
+ required: ['skill_name'],
162
+ },
163
+ },
164
+ {
165
+ name: 'install_skill',
166
+ description: 'Install a global or built-in skill into the current project. Copies the skill file for local customization. Use list_skills to see available skills from all tiers.',
167
+ inputSchema: {
168
+ type: 'object',
169
+ properties: {
170
+ skill_name: { type: 'string', description: 'Skill name to install (e.g. "code-reviewer").' },
171
+ cwd: { type: 'string', description: 'Project directory. Defaults to current working directory.' },
172
+ },
173
+ required: ['skill_name'],
174
+ },
175
+ },
176
+ ];
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Peer consultation — consult_peer tool for architectural consensus.
3
+ * Non-blocking: returns task_id, result is polled via get_task_result.
4
+ *
5
+ * @module agent-pool/tools/consult
6
+ */
7
+
8
+ import { randomUUID } from 'node:crypto';
9
+ import { runGeminiStreaming } from '../runner/gemini-runner.js';
10
+ import { createTask, completeTask, failTask } from './results.js';
11
+
12
+ const PEER_REVIEW_SYSTEM_PROMPT = [
13
+ 'You are a senior software architect participating in a peer review session.',
14
+ 'Another AI agent (Antigravity/Claude) is proposing a technical approach.',
15
+ 'Your role is to critically evaluate the proposal and work toward consensus.',
16
+ '',
17
+ 'RESPONSE FORMAT (strict):',
18
+ '## Verdict: [AGREE | SUGGEST_CHANGES | DISAGREE]',
19
+ '',
20
+ '## Reasoning',
21
+ '[Your detailed technical analysis]',
22
+ '',
23
+ '## Concerns (if any)',
24
+ '[List specific technical concerns]',
25
+ '',
26
+ '## Suggested Changes (if verdict is not AGREE)',
27
+ '[Specific actionable modifications]',
28
+ '',
29
+ '## Final Assessment',
30
+ '[1-2 sentence summary of your position]',
31
+ '',
32
+ 'RULES:',
33
+ '- Be concise but thorough',
34
+ '- Focus on architecture, performance, maintainability, and correctness',
35
+ '- If the proposal is solid, AGREE and explain why',
36
+ '- If you suggest changes, be specific about what and why',
37
+ '- Consider the project conventions: Node.js, ESM, JSDoc, no TypeScript, modular structure',
38
+ '- Respond in the same language as the proposal (Russian or English)',
39
+ ].join('\n');
40
+
41
+ /**
42
+ * Consult a Gemini peer agent for architectural review (non-blocking).
43
+ * Spawns a streaming task and returns task_id immediately.
44
+ *
45
+ * @param {object} args
46
+ * @param {string} args.context - Project context
47
+ * @param {string} args.proposal - Technical proposal
48
+ * @param {string} [args.previous_rounds] - Previous discussion
49
+ * @param {string} [args.cwd] - Working directory
50
+ * @param {string} [args.model] - Model ID
51
+ * @param {string} defaultCwd - Default working directory
52
+ * @returns {{content: Array<{type: string, text: string}>}}
53
+ */
54
+ export function consultPeer(args, defaultCwd) {
55
+ const taskId = randomUUID();
56
+
57
+ const parts = [
58
+ PEER_REVIEW_SYSTEM_PROMPT,
59
+ '',
60
+ '--- CONTEXT ---',
61
+ args.context,
62
+ '',
63
+ '--- PROPOSAL ---',
64
+ args.proposal,
65
+ ];
66
+
67
+ if (args.previous_rounds) {
68
+ parts.push(
69
+ '',
70
+ '--- PREVIOUS DISCUSSION ---',
71
+ args.previous_rounds,
72
+ '',
73
+ 'Based on the previous rounds, evaluate the UPDATED proposal above.',
74
+ 'If your concerns have been addressed, respond with AGREE.',
75
+ );
76
+ }
77
+
78
+ const prompt = parts.join('\n');
79
+
80
+ createTask(taskId, `[peer-review] ${args.proposal.substring(0, 100)}`, 'Peer is reviewing your proposal. Continue with other work while waiting.', 'plan');
81
+
82
+ runGeminiStreaming({
83
+ prompt,
84
+ cwd: args.cwd ?? defaultCwd,
85
+ model: args.model,
86
+ approvalMode: 'plan',
87
+ timeout: 0, // no timeout — runs until completion
88
+ taskId,
89
+ })
90
+ .then((result) => completeTask(taskId, result))
91
+ .catch((err) => failTask(taskId, err.message));
92
+
93
+ return {
94
+ content: [{
95
+ type: 'text',
96
+ text: `🤝 Peer consultation started.\n\n- **Task ID**: \`${taskId}\`\n- **Proposal**: ${args.proposal.substring(0, 120)}...\n\nUse \`get_task_result\` with this task_id to check the verdict.`,
97
+ }],
98
+ };
99
+ }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Task result store — tracks task status, PID, and retrieves results.
3
+ * Supports soft timeout (task continues after timeout), cancel, and post-timeout updates.
4
+ *
5
+ * @module agent-pool/tools/results
6
+ */
7
+
8
+ import { killGroup, getSystemLoad } from '../runner/process-manager.js';
9
+
10
+ /** @type {Map<string, {status: string, prompt: string, approvalMode: string, result: object|null, error: string|null, startedAt: number, completedAt: number|null, pollCount: number, waitHint: string|null, pid: number|null}>} */
11
+ const taskStore = new Map();
12
+
13
+ /** Max number of live events to keep per task (ring buffer) */
14
+ const MAX_LIVE_EVENTS = 200;
15
+
16
+ /** TTL for completed tasks in ms (10 minutes) */
17
+ const TASK_TTL_MS = 10 * 60 * 1000;
18
+
19
+ // ─── Error classification for retry hints ───────────────────
20
+
21
+ /**
22
+ * Classify error text and return a retry hint for the calling agent.
23
+ * @param {string} errorText
24
+ * @returns {string} Retry instruction
25
+ */
26
+ function classifyError(errorText) {
27
+ const lower = errorText.toLowerCase();
28
+
29
+ if (lower.includes('429') || lower.includes('rate limit') || lower.includes('quota') || lower.includes('resource_exhausted')) {
30
+ return '🔄 **Rate limited.** Wait 30-60 seconds, then retry the same task with `delegate_task`.';
31
+ }
32
+ if (lower.includes('network') || lower.includes('econnrefused') || lower.includes('econnreset') || lower.includes('etimedout') || lower.includes('fetch failed') || lower.includes('socket hang up')) {
33
+ return '🔄 **Network error.** Check connectivity and retry the task. This is usually transient.';
34
+ }
35
+ if (lower.includes('401') || lower.includes('403') || lower.includes('unauthenticated') || lower.includes('permission denied') || lower.includes('not authenticated')) {
36
+ return '🔑 **Authentication error.** Run `gemini` in terminal to re-authenticate, then retry.';
37
+ }
38
+ if (lower.includes('enomem') || lower.includes('out of memory') || lower.includes('heap')) {
39
+ return '💾 **Out of memory.** Too many parallel workers. Cancel some tasks with `cancel_task`, then retry.';
40
+ }
41
+ if (lower.includes('spawn') || lower.includes('enoent')) {
42
+ return '⚙️ **Gemini CLI not found.** Install: `npm install -g @google/gemini-cli`';
43
+ }
44
+ return '🔄 **Unexpected error.** You can retry the task with `delegate_task`. If the error persists, try a simpler prompt or check `npx agent-pool-mcp --check`.';
45
+ }
46
+
47
+ // Coaching hints — nudge the agent to think about delegation
48
+ const COACHING_HINTS = [
49
+ 'Think: what else can you do in parallel while this task runs? Delegate another task or work on something independent.',
50
+ 'This worker is busy — don\'t wait idle. Check your plan: is there another step you can start now?',
51
+ 'Pro tip: batch your delegation. Send 2-3 tasks at once, then collect results when all are done.',
52
+ 'Instead of polling, make progress on your main task. Come back to check results after a few steps.',
53
+ 'While the worker handles this, consider: is there a subtask you can delegate to another worker?',
54
+ 'Your time is valuable. Use consult_peer to validate your next architectural decision while this runs.',
55
+ 'Polling too often wastes tokens. Do meaningful work first, then check results.',
56
+ 'Is there a code review, analysis, or research task you can delegate right now?',
57
+ ];
58
+
59
+ /**
60
+ * Create a new task entry in the store.
61
+ *
62
+ * @param {string} taskId - Task UUID
63
+ * @param {string} prompt - Task prompt
64
+ * @param {string} [waitHint] - Custom coaching hint for polling
65
+ * @param {string} [approvalMode] - Approval mode (yolo, auto_edit, plan)
66
+ */
67
+ export function createTask(taskId, prompt, waitHint, approvalMode) {
68
+ taskStore.set(taskId, {
69
+ status: 'running',
70
+ prompt,
71
+ approvalMode: approvalMode ?? 'unknown',
72
+ result: null,
73
+ error: null,
74
+ startedAt: Date.now(),
75
+ completedAt: null,
76
+ pollCount: 0,
77
+ waitHint: waitHint ?? null,
78
+ pid: null,
79
+ liveEvents: [],
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Push a live event to a running task (for progress tracking).
85
+ *
86
+ * @param {string} taskId
87
+ * @param {object} event - Parsed stream-json event
88
+ */
89
+ export function pushTaskEvent(taskId, event) {
90
+ const entry = taskStore.get(taskId);
91
+ if (entry && entry.status === 'running') {
92
+ entry.liveEvents.push(event);
93
+ // Ring buffer: keep only the last MAX_LIVE_EVENTS
94
+ if (entry.liveEvents.length > MAX_LIVE_EVENTS) {
95
+ entry.liveEvents = entry.liveEvents.slice(-MAX_LIVE_EVENTS);
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Associate a PID with a task (called after spawn).
102
+ *
103
+ * @param {string} taskId
104
+ * @param {number} pid
105
+ */
106
+ export function setTaskPid(taskId, pid) {
107
+ const entry = taskStore.get(taskId);
108
+ if (entry) entry.pid = pid;
109
+ }
110
+
111
+ /**
112
+ * Mark a task as completed with result.
113
+ *
114
+ * @param {string} taskId
115
+ * @param {object} result
116
+ */
117
+ export function completeTask(taskId, result) {
118
+ const entry = taskStore.get(taskId);
119
+ if (entry) {
120
+ entry.status = 'done';
121
+ entry.result = result;
122
+ entry.completedAt = Date.now();
123
+ entry.pid = null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Update a task that already resolved via soft timeout with final complete data.
129
+ * Only updates if the task is already 'done' with softTimeout flag.
130
+ *
131
+ * @param {string} taskId
132
+ * @param {object} result - Full result from process completion
133
+ */
134
+ export function updateTaskResult(taskId, result) {
135
+ const entry = taskStore.get(taskId);
136
+ if (entry && entry.status === 'done' && entry.result?.softTimeout) {
137
+ entry.result = result;
138
+ entry.pid = null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Mark a task as failed with error.
144
+ *
145
+ * @param {string} taskId
146
+ * @param {string} errorMessage
147
+ */
148
+ export function failTask(taskId, errorMessage) {
149
+ const entry = taskStore.get(taskId);
150
+ if (entry) {
151
+ entry.status = 'error';
152
+ entry.error = errorMessage;
153
+ entry.completedAt = Date.now();
154
+ entry.pid = null;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Cancel a running task — kill its process and mark as cancelled.
160
+ *
161
+ * @param {string} taskId
162
+ * @returns {{content: Array<{type: string, text: string}>, isError?: boolean}}
163
+ */
164
+ export function cancelTask(taskId) {
165
+ const entry = taskStore.get(taskId);
166
+ if (!entry) {
167
+ return {
168
+ content: [{ type: 'text', text: `❌ Task not found: \`${taskId}\`` }],
169
+ isError: true,
170
+ };
171
+ }
172
+
173
+ if (entry.status !== 'running') {
174
+ return {
175
+ content: [{ type: 'text', text: `⚠️ Task \`${taskId.substring(0, 8)}\` is already ${entry.status}, cannot cancel.` }],
176
+ };
177
+ }
178
+
179
+ const elapsed = ((Date.now() - entry.startedAt) / 1000).toFixed(0);
180
+ let killed = false;
181
+ if (entry.pid) {
182
+ killed = killGroup(entry.pid);
183
+ }
184
+ entry.status = 'cancelled';
185
+ entry.completedAt = Date.now();
186
+ entry.pid = null;
187
+
188
+ return {
189
+ content: [{
190
+ type: 'text',
191
+ text: `🛑 Task \`${taskId.substring(0, 8)}\` cancelled after ${elapsed}s.${killed ? ' Process killed.' : ''}`,
192
+ }],
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Get task entry from store.
198
+ *
199
+ * @param {string} taskId
200
+ * @returns {object|undefined}
201
+ */
202
+ export function getTask(taskId) {
203
+ return taskStore.get(taskId);
204
+ }
205
+
206
+ /**
207
+ * Remove task from store.
208
+ *
209
+ * @param {string} taskId
210
+ */
211
+ export function removeTask(taskId) {
212
+ taskStore.delete(taskId);
213
+ }
214
+
215
+ /**
216
+ * Get summary of all active tasks for inclusion in tool responses.
217
+ *
218
+ * @returns {string|null} Formatted active tasks string, or null if none
219
+ */
220
+ export function getActiveTasks() {
221
+ const active = [...taskStore.entries()]
222
+ .filter(([, entry]) => entry.status === 'running')
223
+ .map(([id, entry]) => {
224
+ const elapsed = ((Date.now() - entry.startedAt) / 1000).toFixed(0);
225
+ const pidInfo = entry.pid ? ` pid:${entry.pid}` : '';
226
+ return `- \`${id.substring(0, 8)}\` (${elapsed}s${pidInfo}) ${entry.prompt.substring(0, 60)}...`;
227
+ });
228
+ if (active.length === 0) return null;
229
+ return `\n\n---\n📋 **Active tasks (${active.length})**:\n${active.join('\n')}`;
230
+ }
231
+
232
+ /**
233
+ * Format task result for MCP response.
234
+ * When task is running, returns coaching hints to encourage parallel work.
235
+ *
236
+ * @param {string} taskId
237
+ * @returns {{content: Array<{type: string, text: string}>, isError?: boolean}}
238
+ */
239
+ export function formatTaskResult(taskId) {
240
+ const entry = taskStore.get(taskId);
241
+ if (!entry) {
242
+ return {
243
+ content: [{ type: 'text', text: `❌ Task not found: \`${taskId}\`` }],
244
+ isError: true,
245
+ };
246
+ }
247
+
248
+ if (entry.status === 'running') {
249
+ const elapsed = ((Date.now() - entry.startedAt) / 1000).toFixed(0);
250
+ entry.pollCount++;
251
+
252
+ // Use custom hint if set, otherwise rotate through coaching hints
253
+ const hint = entry.waitHint
254
+ ? entry.waitHint
255
+ : COACHING_HINTS[(entry.pollCount - 1) % COACHING_HINTS.length];
256
+
257
+ // Build progress from live events
258
+ let progress = '';
259
+ if (entry.liveEvents.length > 0) {
260
+ const tools = entry.liveEvents.filter((e) => e.type === 'tool_use');
261
+ const toolResults = entry.liveEvents.filter((e) => e.type === 'tool_result');
262
+ const messages = entry.liveEvents.filter((e) => e.type === 'message' && e.role === 'assistant');
263
+ const parts = [];
264
+
265
+ // Show last 3 tool calls with args and results
266
+ if (tools.length > 0) {
267
+ const toolLines = tools.slice(-3).map((t) => {
268
+ const name = t.tool_name ?? t.name ?? '?';
269
+ const args = t.parameters ?? t.arguments ?? {};
270
+ // Extract the most meaningful arg — Gemini uses file_path, path, query, etc.
271
+ const detail = args.file_path ?? args.path ?? args.file ?? args.query ?? args.symbol ?? args.command ?? '';
272
+ let shortDetail = '';
273
+ if (typeof detail === 'string' && detail.length > 0) {
274
+ // Absolute paths: always trim from start — the tail (project/file) matters most
275
+ const display = detail.startsWith('/') && detail.length > 30
276
+ ? '…' + detail.slice(-30)
277
+ : detail.length > 60 ? '…' + detail.slice(-55) : detail;
278
+ shortDetail = ` → ${display}`;
279
+ }
280
+
281
+ // Find matching tool_result by tool_id
282
+ let resultInfo = '';
283
+ if (t.tool_id) {
284
+ const result = toolResults.find((r) => r.tool_id === t.tool_id);
285
+ if (result) {
286
+ // Calculate duration from timestamps
287
+ if (t.timestamp && result.timestamp) {
288
+ const duration = ((new Date(result.timestamp) - new Date(t.timestamp)) / 1000).toFixed(1);
289
+ resultInfo += ` (${duration}s)`;
290
+ }
291
+ // Show brief result output
292
+ const output = result.output ?? '';
293
+ if (output && typeof output === 'string' && output.length > 0) {
294
+ resultInfo += ` ${result.status === 'success' ? '✓' : '✗'} ${output.substring(0, 60)}`;
295
+ }
296
+ } else {
297
+ resultInfo = ' ⏳ running...';
298
+ }
299
+ }
300
+ return ` \`${name}\`${shortDetail}${resultInfo}`;
301
+ });
302
+ parts.push(`🔧 Tools (${tools.length}):\n${toolLines.join('\n')}`);
303
+ }
304
+
305
+ // Show last assistant message (agent's thinking/conclusion)
306
+ if (messages.length > 0) {
307
+ const lastMsg = messages[messages.length - 1];
308
+ const text = (lastMsg.content ?? lastMsg.text ?? '').substring(0, 150);
309
+ if (text) parts.push(`💬 ${text}`);
310
+ }
311
+
312
+ if (parts.length > 0) {
313
+ progress = `\n\n**Progress:**\n${parts.join('\n')}`;
314
+ }
315
+ } else if (parseInt(elapsed) > 10) {
316
+ progress = '\n\n⏳ *Cold start — Gemini CLI initialization takes ~15-20s*';
317
+ }
318
+
319
+ // System load awareness during polling
320
+ const load = getSystemLoad();
321
+ const loadInfo = load.warning ? `\n\n${load.warning}` : '';
322
+
323
+ const modeLabel = entry.approvalMode === 'plan' ? '🔒 read-only' : entry.approvalMode === 'yolo' ? '✏️ full-access' : `⚙️ ${entry.approvalMode}`;
324
+
325
+ return {
326
+ content: [{
327
+ type: 'text',
328
+ text: `⏳ Task is still running (${elapsed}s elapsed, ${entry.liveEvents.length} events).\n\n- **Prompt**: ${entry.prompt.substring(0, 100)}...\n- **Mode**: ${modeLabel}${progress}\n\n💡 **${hint}**${loadInfo}\n\nCheck again later with \`get_task_result\`.`,
329
+ }],
330
+ };
331
+ }
332
+
333
+ if (entry.status === 'cancelled') {
334
+ removeTask(taskId);
335
+ return {
336
+ content: [{ type: 'text', text: `🛑 Task was cancelled.` }],
337
+ };
338
+ }
339
+
340
+ if (entry.status === 'error') {
341
+ const errorText = entry.error ?? 'Unknown error';
342
+ const retryHint = classifyError(errorText);
343
+ removeTask(taskId);
344
+ return {
345
+ content: [{ type: 'text', text: `❌ Task failed: ${errorText}\n\n${retryHint}` }],
346
+ isError: true,
347
+ };
348
+ }
349
+
350
+ // Done — format result
351
+ const result = entry.result;
352
+ // Don't remove soft-timeout tasks — process is still running, updateTaskResult will update later
353
+ if (!result.softTimeout) {
354
+ removeTask(taskId);
355
+ }
356
+
357
+ const sections = [];
358
+
359
+ // Soft timeout indicator
360
+ if (result.softTimeout) {
361
+ sections.push(`> ⏳ **Soft timeout** reached after ${result.timeoutSeconds}s. Process may still be running — partial result below.`);
362
+ }
363
+
364
+ // Agent failed with non-zero exit and no response — show diagnostic info
365
+ if (result.exitCode && result.exitCode !== 0 && !result.response) {
366
+ sections.push(`## ⚠️ Agent Failed (exit code ${result.exitCode})\n\nThe agent process terminated without producing a response.`);
367
+ if (result.toolCalls?.length > 0) {
368
+ const lastTools = result.toolCalls.slice(-5).map((t) => `- \`${t.name}\``).join('\n');
369
+ sections.push(`### Last Tool Calls\n\n${lastTools}`);
370
+ }
371
+ if (result.errors?.length > 0) {
372
+ sections.push(`### Errors\n\n${result.errors.join('\n')}`);
373
+ }
374
+ const errorSignal = (result.errors ?? []).join(' ');
375
+ sections.push(`### Recovery\n\n${classifyError(errorSignal || `exit code ${result.exitCode}`)}`);
376
+ }
377
+
378
+ if (result.response) {
379
+ sections.push(`## Agent Response\n\n${result.response}`);
380
+ }
381
+ if (result.toolCalls?.length > 0 && result.response) {
382
+ const toolSummary = result.toolCalls.map((t) => `- **${t.name}**`).join('\n');
383
+ sections.push(`## Tools Used (${result.toolCalls.length})\n\n${toolSummary}`);
384
+ }
385
+ if (result.errors?.length > 0 && result.response) {
386
+ sections.push(`## Errors\n\n${result.errors.join('\n')}`);
387
+ }
388
+ const statParts = [];
389
+ if (entry.approvalMode) statParts.push(`- Mode: ${entry.approvalMode}`);
390
+ if (result.sessionId) statParts.push(`- Session ID: \`${result.sessionId}\``);
391
+ if (result.stats) {
392
+ const s = result.stats;
393
+ const models = Object.keys(s.models ?? {});
394
+ if (models.length > 0) statParts.push(`- Models: ${models.join(', ')}`);
395
+ if (s.total_tokens) statParts.push(`- Tokens: ${s.total_tokens} total`);
396
+ if (s.duration_ms) statParts.push(`- Duration: ${(s.duration_ms / 1000).toFixed(1)}s`);
397
+ }
398
+ if (result.exitCode !== null && result.exitCode !== undefined) {
399
+ statParts.push(`- Exit code: ${result.exitCode}`);
400
+ }
401
+ sections.push(`## Stats\n\n${statParts.join('\n')}`);
402
+
403
+ return {
404
+ content: [{ type: 'text', text: sections.join('\n\n---\n\n') }],
405
+ };
406
+ }
407
+
408
+ // TTL auto-cleanup: purge completed tasks that were never polled
409
+ setInterval(() => {
410
+ const now = Date.now();
411
+ for (const [taskId, entry] of taskStore) {
412
+ if (entry.status !== 'running' && entry.completedAt && (now - entry.completedAt) > TASK_TTL_MS) {
413
+ taskStore.delete(taskId);
414
+ }
415
+ }
416
+ }, 60_000).unref();