@heretyc/subagent-mcp 2.12.3 → 2.12.5-beta.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/README.md +193 -163
- package/dist/advanced-ruleset.py +67 -67
- package/dist/concurrency.js +325 -11
- package/dist/config-scaffold.js +2 -2
- package/dist/drivers.js +340 -30
- package/dist/effort.js +10 -5
- package/dist/global-concurrency.jsonc +39 -34
- package/dist/global-subagent-mcp-config.jsonc +39 -0
- package/dist/index.js +242 -32
- package/dist/pending-permissions.js +193 -0
- package/dist/permission-classes.json +36 -0
- package/dist/permission-engine.js +253 -0
- package/dist/routing-table.json +3561 -3561
- package/dist/ruleset-scaffold.js +1 -1
- package/dist/setup.js +1 -1
- package/dist/status-helpers.js +10 -2
- package/dist/wait-helpers.js +3 -0
- package/dist/zombie.js +16 -3
- package/package.json +69 -69
- package/scripts/postinstall.mjs +102 -102
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { spawn, spawnSync, execSync } from "child_process";
|
|
6
|
-
import { unlinkSync, existsSync, realpathSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { unlinkSync, existsSync, realpathSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import { isAbsolute, basename, join, dirname } from "node:path";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
@@ -11,19 +11,21 @@ import { pathToFileURL } from "url";
|
|
|
11
11
|
import { buildCommand } from "./effort.js";
|
|
12
12
|
import { createProviderDriver } from "./drivers.js";
|
|
13
13
|
import { resolveExeFor } from "./platform.js";
|
|
14
|
-
import { formatLocalIso, selectUnreported } from "./wait-helpers.js";
|
|
14
|
+
import { formatLocalIso, selectUnreported, selectUnreportedPermissionRequested, } from "./wait-helpers.js";
|
|
15
15
|
import { computeStatusTransition, buildLivenessFields, } from "./status-helpers.js";
|
|
16
16
|
import { escapeUntrustedTags, envelopeUntrustedOutput, extractFinalTurn, } from "./output-helpers.js";
|
|
17
17
|
import { consumeStreamChunk, flushStream, isTurnCompletedLine, retainLastN, } from "./stream-helpers.js";
|
|
18
18
|
import { loadRoutingTable, buildCandidates, validatePresence, TASK_CATEGORIES, AUTO_HINT, SPLIT_HINT, } from "./routing.js";
|
|
19
19
|
import { createDeadlockWindow } from "./deadlock.js";
|
|
20
20
|
import { createRulesetGate, RULESET_HARD_FAIL_MSG, } from "./ruleset.js";
|
|
21
|
-
import { CONFIG_FILENAME, NONBLOCKING_CULL_DEPS, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, defaultConfigPath, globalCapMessage, readSlotMetadata, readGlobalCap, releaseSlot, reserveSlot, slotDir, writeSlotMetadata, } from "./concurrency.js";
|
|
21
|
+
import { CONFIG_FILENAME, NONBLOCKING_CULL_DEPS, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, defaultConfigPath, globalCapMessage, readSlotMetadata, readMergedPermissionConfig, readPermissionsCeiling, readGlobalCap, releaseSlot, reserveSlot, slotDir, writeSlotMetadata, } from "./concurrency.js";
|
|
22
|
+
import { shouldReapTerminalButAlive } from "./zombie.js";
|
|
22
23
|
import * as orchestrationMarker from "./orchestration/marker.js";
|
|
23
24
|
import * as modelMode from "./orchestration/model-mode.js";
|
|
24
25
|
import { startLivenessHeartbeat } from "./orchestration/liveness.js";
|
|
25
26
|
import { checkForNpmUpdate } from "./orchestration/update-check.js";
|
|
26
27
|
import { ensureParentMarker } from "./launch-prompt.js";
|
|
28
|
+
import { pendingPermissionManager, } from "./pending-permissions.js";
|
|
27
29
|
const agents = new Map();
|
|
28
30
|
const deadlockWindow = createDeadlockWindow();
|
|
29
31
|
const STDOUT_RING_BYTES = 2 * 1024 * 1024;
|
|
@@ -31,6 +33,60 @@ const STDOUT_RING_BYTES = 2 * 1024 * 1024;
|
|
|
31
33
|
// scoping. The env-check runs lazily at the FIRST launch_agent call; success
|
|
32
34
|
// latches enabled/disabled for the process lifetime, failure never latches.
|
|
33
35
|
const rulesetGate = createRulesetGate();
|
|
36
|
+
function ceilingRank(ceiling) {
|
|
37
|
+
return ceiling === "manual" ? 0 : ceiling === "auto" ? 1 : 2;
|
|
38
|
+
}
|
|
39
|
+
function buildPermissionSnapshot(cwd) {
|
|
40
|
+
const merged = readMergedPermissionConfig(cwd);
|
|
41
|
+
return {
|
|
42
|
+
ceiling: merged.permissionsCeiling,
|
|
43
|
+
escalation: merged.escalation,
|
|
44
|
+
rules: {
|
|
45
|
+
allow: merged.allow,
|
|
46
|
+
ask: merged.ask,
|
|
47
|
+
deny: merged.deny,
|
|
48
|
+
},
|
|
49
|
+
additionalDirectories: merged.additionalDirectories,
|
|
50
|
+
repoConfigChangedSinceFirstSeen: merged.repoConfigChangedSinceFirstSeen,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function isStalePermissive(agent) {
|
|
54
|
+
if (!isLiveAgent(agent))
|
|
55
|
+
return false;
|
|
56
|
+
const current = readPermissionsCeiling();
|
|
57
|
+
return ceilingRank(agent.permissionSnapshot.ceiling) > ceilingRank(current);
|
|
58
|
+
}
|
|
59
|
+
function pendingPermissionSummary(record, now = Date.now()) {
|
|
60
|
+
return {
|
|
61
|
+
request_id: record.request_id,
|
|
62
|
+
tool_name_or_method: record.tool_name_or_method,
|
|
63
|
+
harness_channel: record.harness_channel,
|
|
64
|
+
permission_ceiling: record.permission_ceiling,
|
|
65
|
+
escalation: record.escalation,
|
|
66
|
+
irreversible: record.irreversible,
|
|
67
|
+
escalate_to_human: record.escalate_to_human,
|
|
68
|
+
requested_at: formatLocalIso(record.requested_at),
|
|
69
|
+
age_seconds: Math.floor((now - record.requested_at) / 1000),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
pendingPermissionManager.onAgentQueueChange((agentId, pendingCount) => {
|
|
73
|
+
const agent = agents.get(agentId);
|
|
74
|
+
if (!agent || agent.exitCode !== null)
|
|
75
|
+
return;
|
|
76
|
+
if (pendingCount > 0) {
|
|
77
|
+
if (agent.status === "processing" || agent.status === "stalled") {
|
|
78
|
+
agent.status = "permission_requested";
|
|
79
|
+
agent.waitReported = false;
|
|
80
|
+
updateSlotMetadata(agent);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (agent.status === "permission_requested") {
|
|
84
|
+
agent.status = "processing";
|
|
85
|
+
agent.waitReported = false;
|
|
86
|
+
agent.lastActivity = Date.now();
|
|
87
|
+
updateSlotMetadata(agent);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
34
90
|
// Post-spawn grace window (ms). A provider driver that exits within this window
|
|
35
91
|
// after a successful driver start never launched (not logged in, expired auth, instant
|
|
36
92
|
// crash) — the attempt loop silently advances instead of falsely reporting
|
|
@@ -95,7 +151,9 @@ function slotHeartbeatIntervalMs() {
|
|
|
95
151
|
return Math.max(1000, Math.min(30_000, Math.floor(zombieLiveIdleMs() / 3)));
|
|
96
152
|
}
|
|
97
153
|
function isLiveAgent(agent) {
|
|
98
|
-
return agent.status === "processing" ||
|
|
154
|
+
return (agent.status === "processing" ||
|
|
155
|
+
agent.status === "permission_requested" ||
|
|
156
|
+
agent.status === "stalled");
|
|
99
157
|
}
|
|
100
158
|
function isSameOwnerSlot(agent, slotMeta) {
|
|
101
159
|
if (!slotMeta)
|
|
@@ -171,6 +229,7 @@ function scheduleZombieForceKill(agent) {
|
|
|
171
229
|
timer.unref();
|
|
172
230
|
}
|
|
173
231
|
function markZombieKilled(agent, reason, now) {
|
|
232
|
+
void pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
174
233
|
agent.status = "zombie_killed";
|
|
175
234
|
agent.exitCode = agent.exitCode ?? -1;
|
|
176
235
|
agent.exitedAt = agent.exitedAt ?? now;
|
|
@@ -209,6 +268,7 @@ function applyZombieRecord(record, now) {
|
|
|
209
268
|
const agent = agents.get(record.agent_id);
|
|
210
269
|
if (!agent || agent.status === "zombie_killed")
|
|
211
270
|
return null;
|
|
271
|
+
void pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
212
272
|
agent.status = "zombie_killed";
|
|
213
273
|
agent.exitCode = agent.exitCode ?? -1;
|
|
214
274
|
agent.exitedAt = agent.exitedAt ?? now;
|
|
@@ -246,10 +306,7 @@ function runToolMaintenance() {
|
|
|
246
306
|
}
|
|
247
307
|
const slotMeta = agent.slotPath ? readSlotMetadata(agent.slotPath) : null;
|
|
248
308
|
adoptNewerSlotActivity(agent, slotMeta);
|
|
249
|
-
const terminalButAlive = (agent
|
|
250
|
-
!agent.driver.closed &&
|
|
251
|
-
agent.exitedAt !== null &&
|
|
252
|
-
now - agent.exitedAt > zombieTerminalIdleMs();
|
|
309
|
+
const terminalButAlive = shouldReapTerminalButAlive(agent, now, zombieTerminalIdleMs());
|
|
253
310
|
if (terminalButAlive) {
|
|
254
311
|
const record = markZombieKilled(agent, "terminal_but_alive", now);
|
|
255
312
|
records.push(record);
|
|
@@ -378,6 +435,8 @@ function handleCompletedStdoutLines(agent, lines, at) {
|
|
|
378
435
|
agent.status = "finished";
|
|
379
436
|
if (agent.exitedAt === null)
|
|
380
437
|
agent.exitedAt = at;
|
|
438
|
+
releaseSlot(agent.slotPath ?? null);
|
|
439
|
+
agent.slotPath = null;
|
|
381
440
|
}
|
|
382
441
|
}
|
|
383
442
|
}
|
|
@@ -394,6 +453,15 @@ function cleanupUcSettings(agentState) {
|
|
|
394
453
|
catch { }
|
|
395
454
|
agentState.ucSettingsPath = undefined;
|
|
396
455
|
}
|
|
456
|
+
if (agentState.ucSettingsDir) {
|
|
457
|
+
try {
|
|
458
|
+
if (existsSync(agentState.ucSettingsDir)) {
|
|
459
|
+
rmSync(agentState.ucSettingsDir, { recursive: true, force: true });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch { }
|
|
463
|
+
agentState.ucSettingsDir = undefined;
|
|
464
|
+
}
|
|
397
465
|
}
|
|
398
466
|
// Synchronously reconcile a single agent's status against the pure transition
|
|
399
467
|
// helper. Folds the live process exitCode into AgentState first so an already-
|
|
@@ -442,7 +510,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
|
|
|
442
510
|
const SUBAGENT_INSTRUCTIONS = "SUB-AGENT SESSION: you are a child process launched by subagent-mcp. Follow the parent prompt. Do not treat yourself as the orchestrator, do not re-trigger orchestration carryover, and do not launch further sub-agents unless the parent prompt explicitly assigns that.\n\nMODEL SELECTION MODE (parallel to orchestration-mode, set via the model-selection-mode tool). DEFAULT is \"smart\" and is used whenever unset: in smart, launch_agent REJECTS any call supplying provider/model/effort selectors and the server auto-picks the best model. \"user-approved-overrides\" opens a 30-MINUTE window where selectors are HONORED, enforced LAZILY (the mode reverts to smart on the next launch_agent call after 30 minutes) and re-enabling does NOT extend an active window. HONOR-BASED: you MUST NOT set \"user-approved-overrides\" without explicit interactive USER authorization via the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex); never enable it on your own initiative.";
|
|
443
511
|
const server = new McpServer({
|
|
444
512
|
name: "subagent-mcp",
|
|
445
|
-
version: "2.12.
|
|
513
|
+
version: "2.12.5-beta.0",
|
|
446
514
|
description: "Launches always-interactive local Claude and Codex sub-agent sessions and is the orchestrator's sole launch channel. Claude runs via the Claude Agent SDK over the local Claude Code executable; Codex via `codex app-server` over stdio. The server never calls Anthropic or OpenAI HTTP APIs directly.",
|
|
447
515
|
}, {
|
|
448
516
|
instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
|
|
@@ -452,7 +520,7 @@ const server = new McpServer({
|
|
|
452
520
|
// Best-effort removal of a candidate's temp ultracode settings file after a
|
|
453
521
|
// LAUNCH-TIME failure (the agentState is never registered, so the close handler
|
|
454
522
|
// will not run cleanupUcSettings for it).
|
|
455
|
-
function cleanupUcSettingsPath(ucSettingsPath) {
|
|
523
|
+
function cleanupUcSettingsPath(ucSettingsPath, ucSettingsDir) {
|
|
456
524
|
if (!ucSettingsPath)
|
|
457
525
|
return;
|
|
458
526
|
try {
|
|
@@ -460,6 +528,13 @@ function cleanupUcSettingsPath(ucSettingsPath) {
|
|
|
460
528
|
unlinkSync(ucSettingsPath);
|
|
461
529
|
}
|
|
462
530
|
catch { }
|
|
531
|
+
if (!ucSettingsDir)
|
|
532
|
+
return;
|
|
533
|
+
try {
|
|
534
|
+
if (existsSync(ucSettingsDir))
|
|
535
|
+
rmSync(ucSettingsDir, { recursive: true, force: true });
|
|
536
|
+
}
|
|
537
|
+
catch { }
|
|
463
538
|
}
|
|
464
539
|
// Attempt to spawn + register a single candidate. Resolves to the agent_id on a
|
|
465
540
|
// successful driver start, or a launch-time failure reason string (never throws/rejects).
|
|
@@ -472,14 +547,15 @@ function cleanupUcSettingsPath(ucSettingsPath) {
|
|
|
472
547
|
// 'error' handler stays attached so a LATE spawn error can never crash the
|
|
473
548
|
// process. Any launch-time failure cleans up and is reported so the attempt loop
|
|
474
549
|
// silently advances to the next candidate.
|
|
475
|
-
async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetInfo) {
|
|
550
|
+
async function tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapshot, routingTier, rulesetInfo) {
|
|
476
551
|
// Build the command. haiku ignores effort; pass "high" placeholder for the
|
|
477
552
|
// "none" sentinel (buildCommand drops it for haiku anyway).
|
|
478
553
|
const effortForBuild = candidate.effort === "none" ? "high" : candidate.effort;
|
|
554
|
+
const agentId = randomUUID();
|
|
479
555
|
let buildResult;
|
|
480
556
|
let cmd;
|
|
481
557
|
try {
|
|
482
|
-
buildResult = buildCommand(candidate.provider, candidate.model, effortForBuild, agentCwd);
|
|
558
|
+
buildResult = buildCommand(candidate.provider, candidate.model, effortForBuild, agentCwd, agentId);
|
|
483
559
|
cmd = resolveExe(candidate.provider);
|
|
484
560
|
}
|
|
485
561
|
catch (e) {
|
|
@@ -489,7 +565,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
489
565
|
// Fast-fail absolute paths only. Bare names intentionally rely on PATH; spawn
|
|
490
566
|
// below resolves them and reports ENOENT/EACCES through the same failure path.
|
|
491
567
|
if (isAbsolute(cmd) && !existsSync(cmd)) {
|
|
492
|
-
cleanupUcSettingsPath(buildResult.ucSettingsPath);
|
|
568
|
+
cleanupUcSettingsPath(buildResult.ucSettingsPath, buildResult.ucSettingsDir);
|
|
493
569
|
return { reason: `CLI executable not found: ${cmd}`, failure_type: "permanent" };
|
|
494
570
|
}
|
|
495
571
|
let driver;
|
|
@@ -503,11 +579,12 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
503
579
|
model: candidate.model,
|
|
504
580
|
effort: candidate.effort,
|
|
505
581
|
ucSettingsPath: buildResult.ucSettingsPath,
|
|
582
|
+
ucSettingsDir: buildResult.ucSettingsDir,
|
|
506
583
|
});
|
|
507
584
|
}
|
|
508
585
|
catch (error) {
|
|
509
586
|
// Synchronous spawn throw (rare) — clean up and report as a launch failure.
|
|
510
|
-
cleanupUcSettingsPath(buildResult.ucSettingsPath);
|
|
587
|
+
cleanupUcSettingsPath(buildResult.ucSettingsPath, buildResult.ucSettingsDir);
|
|
511
588
|
const reason = error instanceof Error ? error.message : String(error);
|
|
512
589
|
return { reason, failure_type: classifyFailureReason(reason, "") };
|
|
513
590
|
}
|
|
@@ -547,14 +624,13 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
547
624
|
driver.kill();
|
|
548
625
|
}
|
|
549
626
|
catch { }
|
|
550
|
-
cleanupUcSettingsPath(buildResult.ucSettingsPath);
|
|
627
|
+
cleanupUcSettingsPath(buildResult.ucSettingsPath, buildResult.ucSettingsDir);
|
|
551
628
|
const reason = err instanceof Error ? err.message : String(err);
|
|
552
629
|
return { reason, failure_type: classifyFailureReason(reason, "") };
|
|
553
630
|
}
|
|
554
631
|
// Spawn succeeded. Register the agent exactly as before. Keep a persistent
|
|
555
632
|
// 'error' handler so a LATE spawn error never crashes the process; fold it
|
|
556
633
|
// into stderr rather than throwing.
|
|
557
|
-
const agentId = randomUUID();
|
|
558
634
|
const now = Date.now();
|
|
559
635
|
const agentState = {
|
|
560
636
|
id: agentId,
|
|
@@ -580,7 +656,9 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
580
656
|
startedAt: now,
|
|
581
657
|
lastActivity: now,
|
|
582
658
|
cwd: agentCwd,
|
|
659
|
+
permissionSnapshot,
|
|
583
660
|
ucSettingsPath: buildResult.ucSettingsPath,
|
|
661
|
+
ucSettingsDir: buildResult.ucSettingsDir,
|
|
584
662
|
waitReported: false,
|
|
585
663
|
visibleStream: [],
|
|
586
664
|
streamBuf: "",
|
|
@@ -635,6 +713,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
635
713
|
});
|
|
636
714
|
}
|
|
637
715
|
childProcess.on("close", (code) => {
|
|
716
|
+
void pendingPermissionManager.closeAgent(agentState.id, "agent process exited while permission request was pending");
|
|
638
717
|
// Flush any buffered trailing stdout line (final event may arrive without a
|
|
639
718
|
// terminating newline) so its visible item is not lost.
|
|
640
719
|
if (agentState.streamBuf) {
|
|
@@ -812,6 +891,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
812
891
|
// mutates the body). This is what makes the child first-line exemption fire.
|
|
813
892
|
const prompt = ensureParentMarker(params.prompt);
|
|
814
893
|
const agentCwd = params.cwd || process.cwd();
|
|
894
|
+
const permissionSnapshot = buildPermissionSnapshot(agentCwd);
|
|
815
895
|
// 1-5. Param-presence validation (zod already constrains task_category, but
|
|
816
896
|
// hard-validate so the spec error text — valid list + hints, and the
|
|
817
897
|
// effort-before-model ordering — is what the caller sees). Pure,
|
|
@@ -914,7 +994,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
914
994
|
let launched = false;
|
|
915
995
|
try {
|
|
916
996
|
for (const candidate of candidates) {
|
|
917
|
-
const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
|
|
997
|
+
const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapshot, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
|
|
918
998
|
? { applied: true, originalSelection: rulesetOriginalSelection }
|
|
919
999
|
: undefined);
|
|
920
1000
|
if ("agentId" in outcome) {
|
|
@@ -955,6 +1035,15 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
955
1035
|
model: candidate.model,
|
|
956
1036
|
effort: candidate.effort,
|
|
957
1037
|
task_category,
|
|
1038
|
+
permissions_applied: {
|
|
1039
|
+
ceiling: permissionSnapshot.ceiling,
|
|
1040
|
+
escalation: permissionSnapshot.escalation,
|
|
1041
|
+
allow_count: permissionSnapshot.rules.allow?.length ?? 0,
|
|
1042
|
+
ask_count: permissionSnapshot.rules.ask?.length ?? 0,
|
|
1043
|
+
deny_count: permissionSnapshot.rules.deny?.length ?? 0,
|
|
1044
|
+
additional_directories_count: permissionSnapshot.additionalDirectories?.length ?? 0,
|
|
1045
|
+
repo_config_changed_since_first_seen: permissionSnapshot.repoConfigChangedSinceFirstSeen ?? false,
|
|
1046
|
+
},
|
|
958
1047
|
...(rulesetApplied
|
|
959
1048
|
? {
|
|
960
1049
|
ruleset_applied: true,
|
|
@@ -1012,7 +1101,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
1012
1101
|
}) // ponytail: launch_agent still reaps, but callers do not receive zombie_report.
|
|
1013
1102
|
);
|
|
1014
1103
|
// Tool 2: poll_agent
|
|
1015
|
-
server.tool("poll_agent", "Get an agent's current status and output. `processing` = ALIVE with visible provider activity in the last 10 min; `stalled` = ALIVE but no visible provider stream item for 10 min (thinking, or awaiting a temp-file handoff) — NOT dead, so prefer `wait`/re-poll over killing. Always returns `alive` + `idle_seconds`, plus `recent_stream` (last 3 timestamped visible stream items) and a `hint` while stalled. `verbose: true` also returns `final_output`, the agent's final assistant turn from its captured stdout.", {
|
|
1104
|
+
server.tool("poll_agent", "Get an agent's current status and output. `processing` = ALIVE with visible provider activity in the last 10 min; `stalled` = ALIVE but no visible provider stream item for 10 min (thinking, or awaiting a temp-file handoff) — NOT dead, so prefer `wait`/re-poll over killing. Polling refreshes the agent's idle clock. Always returns `alive` + `idle_seconds`, plus `recent_stream` (last 3 timestamped visible stream items) and a `hint` while stalled. `verbose: true` also returns `final_output`, the agent's final assistant turn from its captured stdout.", {
|
|
1016
1105
|
agent_id: z.string(),
|
|
1017
1106
|
verbose: z.boolean().optional().default(false),
|
|
1018
1107
|
}, withMaintenance(async (params) => {
|
|
@@ -1032,6 +1121,7 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
1032
1121
|
// completed/failed immediately (no up-to-10s health-monitor lag).
|
|
1033
1122
|
const now = Date.now();
|
|
1034
1123
|
reconcileAgent(agent, now);
|
|
1124
|
+
agent.lastActivity = now;
|
|
1035
1125
|
const escapedStdout = escapeUntrustedTags(agent.stdout);
|
|
1036
1126
|
const escapedStderr = escapeUntrustedTags(agent.stderr);
|
|
1037
1127
|
const stdoutTail = envelopeUntrustedOutput(escapedStdout.length > 2000
|
|
@@ -1055,6 +1145,17 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
1055
1145
|
last_activity: agent.lastActivity,
|
|
1056
1146
|
cwd: agent.cwd,
|
|
1057
1147
|
...liveness,
|
|
1148
|
+
...(pendingPermissionManager.pendingCount(agent.id) > 0
|
|
1149
|
+
? {
|
|
1150
|
+
pending_permissions: pendingPermissionManager.pendingForAgent(agent.id),
|
|
1151
|
+
}
|
|
1152
|
+
: {}),
|
|
1153
|
+
...(isStalePermissive(agent)
|
|
1154
|
+
? {
|
|
1155
|
+
stale_permissive: true,
|
|
1156
|
+
stale_permissive_hint: "agent launched under a more permissive ceiling than current config; consider kill_agent",
|
|
1157
|
+
}
|
|
1158
|
+
: {}),
|
|
1058
1159
|
...(agent.rulesetApplied
|
|
1059
1160
|
? {
|
|
1060
1161
|
ruleset_applied: true,
|
|
@@ -1085,7 +1186,7 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
1085
1186
|
};
|
|
1086
1187
|
}, { includeZombieReport: true }));
|
|
1087
1188
|
// Tool 3: kill_agent
|
|
1088
|
-
server.tool("kill_agent", "Terminate a live agent/session (status `processing`, `stalled`, or turn-finished but still interactive) by immediately force-killing its managed driver. No-op for already-terminal closed agents.", {
|
|
1189
|
+
server.tool("kill_agent", "Terminate a live agent/session (status `processing`, `stalled`, or turn-finished but still interactive) by immediately force-killing its managed driver. A finished agent is force-killed automatically after 6 minutes of no `send_message`/`poll_agent` activity; call `kill_agent` sooner to free resources immediately. No-op for already-terminal closed agents.", {
|
|
1089
1190
|
agent_id: z.string(),
|
|
1090
1191
|
}, withMaintenance(async (params) => {
|
|
1091
1192
|
const agent = agents.get(params.agent_id);
|
|
@@ -1103,7 +1204,10 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1103
1204
|
// Kill applies to ALL live driver states (processing, stalled, or finished
|
|
1104
1205
|
// current turn with an open interactive session). Closed terminal agents are
|
|
1105
1206
|
// a no-op.
|
|
1106
|
-
const isLive = (agent.status === "processing" ||
|
|
1207
|
+
const isLive = (agent.status === "processing" ||
|
|
1208
|
+
agent.status === "permission_requested" ||
|
|
1209
|
+
agent.status === "stalled" ||
|
|
1210
|
+
agent.status === "finished") &&
|
|
1107
1211
|
!agent.driver.closed;
|
|
1108
1212
|
if (!isLive) {
|
|
1109
1213
|
return {
|
|
@@ -1123,6 +1227,7 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1123
1227
|
// Immediately force-kill the managed process tree — no graceful SIGTERM
|
|
1124
1228
|
// grace period. On Windows, taskkill /t /f tears down the whole tree; on
|
|
1125
1229
|
// POSIX, SIGKILL the process (close handler records the real exit code).
|
|
1230
|
+
await pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
1126
1231
|
agent.status = "stopped";
|
|
1127
1232
|
agent.driver.kill();
|
|
1128
1233
|
releaseSlot(agent.slotPath ?? null);
|
|
@@ -1164,7 +1269,52 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1164
1269
|
};
|
|
1165
1270
|
}
|
|
1166
1271
|
}));
|
|
1167
|
-
// Tool 4:
|
|
1272
|
+
// Tool 4: respond_permission (parents only; children cannot approve other children)
|
|
1273
|
+
if (process.env.SUBAGENT_MCP_SUBAGENT !== "1") {
|
|
1274
|
+
server.tool("respond_permission", "Answer a parked permission request for an agent. One-time only; does not create session-wide approvals. If request_id is omitted, answers the agent's oldest pending request.", {
|
|
1275
|
+
agent_id: z.string(),
|
|
1276
|
+
request_id: z.string().optional(),
|
|
1277
|
+
decision: z.enum(["allow", "deny"]),
|
|
1278
|
+
reason: z.string().optional(),
|
|
1279
|
+
}, withMaintenance(async (params) => {
|
|
1280
|
+
const agent = agents.get(params.agent_id);
|
|
1281
|
+
if (!agent) {
|
|
1282
|
+
return {
|
|
1283
|
+
content: [{ type: "text", text: `Error: Agent ${params.agent_id} not found` }],
|
|
1284
|
+
isError: true,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
try {
|
|
1288
|
+
const answered = await pendingPermissionManager.respond(agent.id, params.request_id, params.decision, params.reason);
|
|
1289
|
+
return {
|
|
1290
|
+
content: [
|
|
1291
|
+
{
|
|
1292
|
+
type: "text",
|
|
1293
|
+
text: JSON.stringify({
|
|
1294
|
+
agent_id: agent.id,
|
|
1295
|
+
request_id: answered.request_id,
|
|
1296
|
+
decision: answered.answer,
|
|
1297
|
+
status: agent.status,
|
|
1298
|
+
pending_permission_count: pendingPermissionManager.pendingCount(agent.id),
|
|
1299
|
+
}),
|
|
1300
|
+
},
|
|
1301
|
+
],
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
catch (error) {
|
|
1305
|
+
return {
|
|
1306
|
+
content: [
|
|
1307
|
+
{
|
|
1308
|
+
type: "text",
|
|
1309
|
+
text: `Error responding to permission request: ${error instanceof Error ? error.message : String(error)}`,
|
|
1310
|
+
},
|
|
1311
|
+
],
|
|
1312
|
+
isError: true,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}));
|
|
1316
|
+
}
|
|
1317
|
+
// Tool 5: send_message
|
|
1168
1318
|
server.tool("send_message", "Enqueue a user message for an open interactive agent session. Observe output with poll_agent or wait.", {
|
|
1169
1319
|
agent_id: z.string(),
|
|
1170
1320
|
message: z.string().min(1),
|
|
@@ -1181,7 +1331,10 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1181
1331
|
isError: true,
|
|
1182
1332
|
};
|
|
1183
1333
|
}
|
|
1184
|
-
const isLive = (agent.status === "processing" ||
|
|
1334
|
+
const isLive = (agent.status === "processing" ||
|
|
1335
|
+
agent.status === "permission_requested" ||
|
|
1336
|
+
agent.status === "stalled" ||
|
|
1337
|
+
agent.status === "finished") &&
|
|
1185
1338
|
!agent.driver.closed;
|
|
1186
1339
|
if (!isLive) {
|
|
1187
1340
|
return {
|
|
@@ -1194,6 +1347,19 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1194
1347
|
isError: true,
|
|
1195
1348
|
};
|
|
1196
1349
|
}
|
|
1350
|
+
const pendingCount = pendingPermissionManager.pendingCount(agent.id);
|
|
1351
|
+
if (pendingCount > 0) {
|
|
1352
|
+
return {
|
|
1353
|
+
content: [
|
|
1354
|
+
{
|
|
1355
|
+
type: "text",
|
|
1356
|
+
text: `Error: agent has ${pendingCount} pending permission request(s); ` +
|
|
1357
|
+
`call respond_permission before sending further messages`,
|
|
1358
|
+
},
|
|
1359
|
+
],
|
|
1360
|
+
isError: true,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1197
1363
|
try {
|
|
1198
1364
|
await agent.driver.send(params.message);
|
|
1199
1365
|
const now = Date.now();
|
|
@@ -1232,7 +1398,7 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1232
1398
|
};
|
|
1233
1399
|
}
|
|
1234
1400
|
}));
|
|
1235
|
-
// Tool
|
|
1401
|
+
// Tool 6: list_agents
|
|
1236
1402
|
server.tool("list_agents", "List all agents with token-efficient core metrics (status, `alive`, `idle_seconds`). `stalled` is ALIVE-but-quiet, NOT dead (full status semantics on poll_agent). Use `poll_agent` for per-agent stream items, hints, and final output.", {}, withMaintenance(async () => {
|
|
1237
1403
|
const now = Date.now();
|
|
1238
1404
|
const agentList = Array.from(agents.values()).map((agent) => {
|
|
@@ -1249,6 +1415,8 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
|
|
|
1249
1415
|
started_at: agent.startedAt,
|
|
1250
1416
|
last_activity: agent.lastActivity,
|
|
1251
1417
|
cwd_basename: basename(agent.cwd),
|
|
1418
|
+
pending_permission_count: pendingPermissionManager.pendingCount(agent.id),
|
|
1419
|
+
...(isStalePermissive(agent) ? { stale_permissive: true } : {}),
|
|
1252
1420
|
...buildLivenessFields(agent.status, agent.exitCode, agent.lastActivity, now, false),
|
|
1253
1421
|
};
|
|
1254
1422
|
});
|
|
@@ -1261,8 +1429,8 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
|
|
|
1261
1429
|
],
|
|
1262
1430
|
};
|
|
1263
1431
|
}, { includeZombieReport: true }));
|
|
1264
|
-
// Tool
|
|
1265
|
-
server.tool("wait", "Blocks until one or more sub-agents reach a reportable state (turn-finished, errored, stopped, or zombie_killed), returning exit code when known + local-time timestamp; or returns the live-job list after a 15-minute timeout. This is how you learn an agent finished — do NOT poll-loop. A `finished` agent with null exit_code is still alive and accepts `send_message`;
|
|
1432
|
+
// Tool 7: wait
|
|
1433
|
+
server.tool("wait", "Blocks until one or more sub-agents reach a reportable state (turn-finished, errored, stopped, or zombie_killed), returning exit code when known + local-time timestamp; or returns the live-job list after a 15-minute timeout. This is how you learn an agent finished — do NOT poll-loop. A `finished` agent with null exit_code is still alive and accepts `send_message`; `send_message`/`poll_agent` activity keeps it alive, and 6 minutes idle auto-kills it. A `stalled` agent is still ALIVE and does NOT end the wait. `verbose: true` adds each finished agent's `final_output`.", {
|
|
1266
1434
|
verbose: z.boolean().optional().default(false),
|
|
1267
1435
|
}, withMaintenance(async (params, zombieRecords) => {
|
|
1268
1436
|
const { verbose } = params;
|
|
@@ -1291,15 +1459,43 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1291
1459
|
started_at_local: formatLocalIso(a.startedAt),
|
|
1292
1460
|
last_activity_local: formatLocalIso(a.lastActivity),
|
|
1293
1461
|
elapsed_ms: now - a.startedAt,
|
|
1462
|
+
...(isStalePermissive(a) ? { stale_permissive: true } : {}),
|
|
1463
|
+
...(a.status === "permission_requested"
|
|
1464
|
+
? {
|
|
1465
|
+
pending_permissions: pendingPermissionManager
|
|
1466
|
+
.pendingForAgent(a.id)
|
|
1467
|
+
.map((p) => pendingPermissionSummary(p, now)),
|
|
1468
|
+
}
|
|
1469
|
+
: {}),
|
|
1470
|
+
});
|
|
1471
|
+
const buildPermissionRequestedEntry = (a, now) => ({
|
|
1472
|
+
id: a.id,
|
|
1473
|
+
provider: a.provider,
|
|
1474
|
+
model: a.model,
|
|
1475
|
+
status: a.status,
|
|
1476
|
+
...(isStalePermissive(a) ? { stale_permissive: true } : {}),
|
|
1477
|
+
pending_permissions: pendingPermissionManager
|
|
1478
|
+
.pendingForAgent(a.id)
|
|
1479
|
+
.map((p) => pendingPermissionSummary(p, now)),
|
|
1294
1480
|
});
|
|
1295
1481
|
// Step 1: collect already-terminal unreported agents
|
|
1296
1482
|
const allAgents = Array.from(agents.values());
|
|
1297
1483
|
let unreported = selectUnreported(allAgents);
|
|
1298
|
-
|
|
1484
|
+
let unreportedPermissionRequested = selectUnreportedPermissionRequested(allAgents);
|
|
1485
|
+
if (unreported.length > 0 || unreportedPermissionRequested.length > 0) {
|
|
1299
1486
|
// Mark reported synchronously before building return (single-threaded JS → atomic)
|
|
1300
1487
|
for (const a of unreported)
|
|
1301
1488
|
a.waitReported = true;
|
|
1302
|
-
const
|
|
1489
|
+
for (const a of unreportedPermissionRequested)
|
|
1490
|
+
a.waitReported = true;
|
|
1491
|
+
const payload = {
|
|
1492
|
+
...(unreported.length > 0 ? { finished: unreported.map(buildFinishedEntry) } : {}),
|
|
1493
|
+
...(unreportedPermissionRequested.length > 0
|
|
1494
|
+
? {
|
|
1495
|
+
permission_requested: unreportedPermissionRequested.map((a) => buildPermissionRequestedEntry(a, Date.now())),
|
|
1496
|
+
}
|
|
1497
|
+
: {}),
|
|
1498
|
+
};
|
|
1303
1499
|
return {
|
|
1304
1500
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1305
1501
|
};
|
|
@@ -1308,6 +1504,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1308
1504
|
// `stalled` is a LIVE state — it keeps the wait pending, it never ends it.
|
|
1309
1505
|
const TERMINAL_SET = new Set(["finished", "errored", "stopped", "zombie_killed"]);
|
|
1310
1506
|
const hasPending = Array.from(agents.values()).some((a) => a.status === "processing" ||
|
|
1507
|
+
a.status === "permission_requested" ||
|
|
1311
1508
|
a.status === "stalled" ||
|
|
1312
1509
|
(TERMINAL_SET.has(a.status) && a.exitedAt === null));
|
|
1313
1510
|
if (!hasPending) {
|
|
@@ -1322,12 +1519,23 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1322
1519
|
// Step 3: block-poll until a terminal agent appears or deadline passes
|
|
1323
1520
|
while (Date.now() < deadline) {
|
|
1324
1521
|
await sleep(250);
|
|
1325
|
-
unreported = selectUnreported(Array.from(agents.values()));
|
|
1326
1522
|
zombieRecords.push(...runToolMaintenance());
|
|
1327
|
-
|
|
1523
|
+
const loopAgents = Array.from(agents.values());
|
|
1524
|
+
unreported = selectUnreported(loopAgents);
|
|
1525
|
+
unreportedPermissionRequested = selectUnreportedPermissionRequested(loopAgents);
|
|
1526
|
+
if (unreported.length > 0 || unreportedPermissionRequested.length > 0) {
|
|
1328
1527
|
for (const a of unreported)
|
|
1329
1528
|
a.waitReported = true;
|
|
1330
|
-
const
|
|
1529
|
+
for (const a of unreportedPermissionRequested)
|
|
1530
|
+
a.waitReported = true;
|
|
1531
|
+
const payload = {
|
|
1532
|
+
...(unreported.length > 0 ? { finished: unreported.map(buildFinishedEntry) } : {}),
|
|
1533
|
+
...(unreportedPermissionRequested.length > 0
|
|
1534
|
+
? {
|
|
1535
|
+
permission_requested: unreportedPermissionRequested.map((a) => buildPermissionRequestedEntry(a, Date.now())),
|
|
1536
|
+
}
|
|
1537
|
+
: {}),
|
|
1538
|
+
};
|
|
1331
1539
|
return {
|
|
1332
1540
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1333
1541
|
};
|
|
@@ -1335,7 +1543,9 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1335
1543
|
}
|
|
1336
1544
|
// Step 4: timeout — return still-running jobs
|
|
1337
1545
|
const now = Date.now();
|
|
1338
|
-
const stillRunning = Array.from(agents.values()).filter((a) => a.status === "processing" ||
|
|
1546
|
+
const stillRunning = Array.from(agents.values()).filter((a) => a.status === "processing" ||
|
|
1547
|
+
a.status === "permission_requested" ||
|
|
1548
|
+
a.status === "stalled");
|
|
1339
1549
|
const payload = {
|
|
1340
1550
|
timed_out: true,
|
|
1341
1551
|
elapsed_minutes: 15,
|
|
@@ -1346,7 +1556,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1346
1556
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1347
1557
|
};
|
|
1348
1558
|
}));
|
|
1349
|
-
// Tool
|
|
1559
|
+
// Tool 8: orchestration-mode
|
|
1350
1560
|
server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF for THIS session only, omit = query. SOLE CHANNEL holds in BOTH states: subagent-mcp is the only sanctioned way to launch sub-agents; toggling OFF does not lift that. WHAT: default-ON mode for LONG-HORIZON work that would fill the context window if run inline; when ON act as a delegate-ONLY orchestrator — delegate every step, inline-by-right does not exist, a non-delegable atomic step needs a one-time user-approved exception via the structured-question tool (state which + why). OFF upgrade check: a long-horizon task = any whose TOTAL context footprint (input read + output produced) exceeds 200 lines, measured CUMULATIVELY since your last upgrade ask; after EVERY user turn, if it qualifies, STOP and ask whether to remain enabled (every qualifying turn; a decline does not latch; reset the count only when you ask). PERSISTENCE: a permitted disable applies to THIS session only, resumes ON next new session (or after the 2h backstop), no mid-session re-enable. CARRYOVER: if ON was inherited from a prior session (carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker) — notify the user it auto-activated and confirm keeping it ON. DISABLE: never on your own initiative; you may PROPOSE OFF on task-fit mismatch, but only EXPLICIT user permission (AskUserQuestion on Claude, request-user-input on Codex) may set enabled:false. Per-turn injection fires only in CLI hosts that load the bundled hook; desktop hosts toggle the marker but inject nothing (documented degradation). Full operating model is in this server's MCP `instructions` (read once at initialize) — this is the operational summary only.", {
|
|
1351
1561
|
enabled: z.boolean().optional(),
|
|
1352
1562
|
}, withMaintenance(async (params) => {
|