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
|
@@ -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();
|