@heretyc/subagent-mcp 2.12.4 → 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 +234 -25
- 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/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,20 +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
22
|
import { shouldReapTerminalButAlive } from "./zombie.js";
|
|
23
23
|
import * as orchestrationMarker from "./orchestration/marker.js";
|
|
24
24
|
import * as modelMode from "./orchestration/model-mode.js";
|
|
25
25
|
import { startLivenessHeartbeat } from "./orchestration/liveness.js";
|
|
26
26
|
import { checkForNpmUpdate } from "./orchestration/update-check.js";
|
|
27
27
|
import { ensureParentMarker } from "./launch-prompt.js";
|
|
28
|
+
import { pendingPermissionManager, } from "./pending-permissions.js";
|
|
28
29
|
const agents = new Map();
|
|
29
30
|
const deadlockWindow = createDeadlockWindow();
|
|
30
31
|
const STDOUT_RING_BYTES = 2 * 1024 * 1024;
|
|
@@ -32,6 +33,60 @@ const STDOUT_RING_BYTES = 2 * 1024 * 1024;
|
|
|
32
33
|
// scoping. The env-check runs lazily at the FIRST launch_agent call; success
|
|
33
34
|
// latches enabled/disabled for the process lifetime, failure never latches.
|
|
34
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
|
+
});
|
|
35
90
|
// Post-spawn grace window (ms). A provider driver that exits within this window
|
|
36
91
|
// after a successful driver start never launched (not logged in, expired auth, instant
|
|
37
92
|
// crash) — the attempt loop silently advances instead of falsely reporting
|
|
@@ -96,7 +151,9 @@ function slotHeartbeatIntervalMs() {
|
|
|
96
151
|
return Math.max(1000, Math.min(30_000, Math.floor(zombieLiveIdleMs() / 3)));
|
|
97
152
|
}
|
|
98
153
|
function isLiveAgent(agent) {
|
|
99
|
-
return agent.status === "processing" ||
|
|
154
|
+
return (agent.status === "processing" ||
|
|
155
|
+
agent.status === "permission_requested" ||
|
|
156
|
+
agent.status === "stalled");
|
|
100
157
|
}
|
|
101
158
|
function isSameOwnerSlot(agent, slotMeta) {
|
|
102
159
|
if (!slotMeta)
|
|
@@ -172,6 +229,7 @@ function scheduleZombieForceKill(agent) {
|
|
|
172
229
|
timer.unref();
|
|
173
230
|
}
|
|
174
231
|
function markZombieKilled(agent, reason, now) {
|
|
232
|
+
void pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
175
233
|
agent.status = "zombie_killed";
|
|
176
234
|
agent.exitCode = agent.exitCode ?? -1;
|
|
177
235
|
agent.exitedAt = agent.exitedAt ?? now;
|
|
@@ -210,6 +268,7 @@ function applyZombieRecord(record, now) {
|
|
|
210
268
|
const agent = agents.get(record.agent_id);
|
|
211
269
|
if (!agent || agent.status === "zombie_killed")
|
|
212
270
|
return null;
|
|
271
|
+
void pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
213
272
|
agent.status = "zombie_killed";
|
|
214
273
|
agent.exitCode = agent.exitCode ?? -1;
|
|
215
274
|
agent.exitedAt = agent.exitedAt ?? now;
|
|
@@ -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,
|
|
@@ -1056,6 +1145,17 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
1056
1145
|
last_activity: agent.lastActivity,
|
|
1057
1146
|
cwd: agent.cwd,
|
|
1058
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
|
+
: {}),
|
|
1059
1159
|
...(agent.rulesetApplied
|
|
1060
1160
|
? {
|
|
1061
1161
|
ruleset_applied: true,
|
|
@@ -1104,7 +1204,10 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1104
1204
|
// Kill applies to ALL live driver states (processing, stalled, or finished
|
|
1105
1205
|
// current turn with an open interactive session). Closed terminal agents are
|
|
1106
1206
|
// a no-op.
|
|
1107
|
-
const isLive = (agent.status === "processing" ||
|
|
1207
|
+
const isLive = (agent.status === "processing" ||
|
|
1208
|
+
agent.status === "permission_requested" ||
|
|
1209
|
+
agent.status === "stalled" ||
|
|
1210
|
+
agent.status === "finished") &&
|
|
1108
1211
|
!agent.driver.closed;
|
|
1109
1212
|
if (!isLive) {
|
|
1110
1213
|
return {
|
|
@@ -1124,6 +1227,7 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1124
1227
|
// Immediately force-kill the managed process tree — no graceful SIGTERM
|
|
1125
1228
|
// grace period. On Windows, taskkill /t /f tears down the whole tree; on
|
|
1126
1229
|
// POSIX, SIGKILL the process (close handler records the real exit code).
|
|
1230
|
+
await pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
1127
1231
|
agent.status = "stopped";
|
|
1128
1232
|
agent.driver.kill();
|
|
1129
1233
|
releaseSlot(agent.slotPath ?? null);
|
|
@@ -1165,7 +1269,52 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1165
1269
|
};
|
|
1166
1270
|
}
|
|
1167
1271
|
}));
|
|
1168
|
-
// 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
|
|
1169
1318
|
server.tool("send_message", "Enqueue a user message for an open interactive agent session. Observe output with poll_agent or wait.", {
|
|
1170
1319
|
agent_id: z.string(),
|
|
1171
1320
|
message: z.string().min(1),
|
|
@@ -1182,7 +1331,10 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1182
1331
|
isError: true,
|
|
1183
1332
|
};
|
|
1184
1333
|
}
|
|
1185
|
-
const isLive = (agent.status === "processing" ||
|
|
1334
|
+
const isLive = (agent.status === "processing" ||
|
|
1335
|
+
agent.status === "permission_requested" ||
|
|
1336
|
+
agent.status === "stalled" ||
|
|
1337
|
+
agent.status === "finished") &&
|
|
1186
1338
|
!agent.driver.closed;
|
|
1187
1339
|
if (!isLive) {
|
|
1188
1340
|
return {
|
|
@@ -1195,6 +1347,19 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1195
1347
|
isError: true,
|
|
1196
1348
|
};
|
|
1197
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
|
+
}
|
|
1198
1363
|
try {
|
|
1199
1364
|
await agent.driver.send(params.message);
|
|
1200
1365
|
const now = Date.now();
|
|
@@ -1233,7 +1398,7 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1233
1398
|
};
|
|
1234
1399
|
}
|
|
1235
1400
|
}));
|
|
1236
|
-
// Tool
|
|
1401
|
+
// Tool 6: list_agents
|
|
1237
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 () => {
|
|
1238
1403
|
const now = Date.now();
|
|
1239
1404
|
const agentList = Array.from(agents.values()).map((agent) => {
|
|
@@ -1250,6 +1415,8 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
|
|
|
1250
1415
|
started_at: agent.startedAt,
|
|
1251
1416
|
last_activity: agent.lastActivity,
|
|
1252
1417
|
cwd_basename: basename(agent.cwd),
|
|
1418
|
+
pending_permission_count: pendingPermissionManager.pendingCount(agent.id),
|
|
1419
|
+
...(isStalePermissive(agent) ? { stale_permissive: true } : {}),
|
|
1253
1420
|
...buildLivenessFields(agent.status, agent.exitCode, agent.lastActivity, now, false),
|
|
1254
1421
|
};
|
|
1255
1422
|
});
|
|
@@ -1262,7 +1429,7 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
|
|
|
1262
1429
|
],
|
|
1263
1430
|
};
|
|
1264
1431
|
}, { includeZombieReport: true }));
|
|
1265
|
-
// Tool
|
|
1432
|
+
// Tool 7: wait
|
|
1266
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`.", {
|
|
1267
1434
|
verbose: z.boolean().optional().default(false),
|
|
1268
1435
|
}, withMaintenance(async (params, zombieRecords) => {
|
|
@@ -1292,15 +1459,43 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1292
1459
|
started_at_local: formatLocalIso(a.startedAt),
|
|
1293
1460
|
last_activity_local: formatLocalIso(a.lastActivity),
|
|
1294
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)),
|
|
1295
1480
|
});
|
|
1296
1481
|
// Step 1: collect already-terminal unreported agents
|
|
1297
1482
|
const allAgents = Array.from(agents.values());
|
|
1298
1483
|
let unreported = selectUnreported(allAgents);
|
|
1299
|
-
|
|
1484
|
+
let unreportedPermissionRequested = selectUnreportedPermissionRequested(allAgents);
|
|
1485
|
+
if (unreported.length > 0 || unreportedPermissionRequested.length > 0) {
|
|
1300
1486
|
// Mark reported synchronously before building return (single-threaded JS → atomic)
|
|
1301
1487
|
for (const a of unreported)
|
|
1302
1488
|
a.waitReported = true;
|
|
1303
|
-
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
|
+
};
|
|
1304
1499
|
return {
|
|
1305
1500
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1306
1501
|
};
|
|
@@ -1309,6 +1504,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1309
1504
|
// `stalled` is a LIVE state — it keeps the wait pending, it never ends it.
|
|
1310
1505
|
const TERMINAL_SET = new Set(["finished", "errored", "stopped", "zombie_killed"]);
|
|
1311
1506
|
const hasPending = Array.from(agents.values()).some((a) => a.status === "processing" ||
|
|
1507
|
+
a.status === "permission_requested" ||
|
|
1312
1508
|
a.status === "stalled" ||
|
|
1313
1509
|
(TERMINAL_SET.has(a.status) && a.exitedAt === null));
|
|
1314
1510
|
if (!hasPending) {
|
|
@@ -1323,12 +1519,23 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1323
1519
|
// Step 3: block-poll until a terminal agent appears or deadline passes
|
|
1324
1520
|
while (Date.now() < deadline) {
|
|
1325
1521
|
await sleep(250);
|
|
1326
|
-
unreported = selectUnreported(Array.from(agents.values()));
|
|
1327
1522
|
zombieRecords.push(...runToolMaintenance());
|
|
1328
|
-
|
|
1523
|
+
const loopAgents = Array.from(agents.values());
|
|
1524
|
+
unreported = selectUnreported(loopAgents);
|
|
1525
|
+
unreportedPermissionRequested = selectUnreportedPermissionRequested(loopAgents);
|
|
1526
|
+
if (unreported.length > 0 || unreportedPermissionRequested.length > 0) {
|
|
1329
1527
|
for (const a of unreported)
|
|
1330
1528
|
a.waitReported = true;
|
|
1331
|
-
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
|
+
};
|
|
1332
1539
|
return {
|
|
1333
1540
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1334
1541
|
};
|
|
@@ -1336,7 +1543,9 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1336
1543
|
}
|
|
1337
1544
|
// Step 4: timeout — return still-running jobs
|
|
1338
1545
|
const now = Date.now();
|
|
1339
|
-
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");
|
|
1340
1549
|
const payload = {
|
|
1341
1550
|
timed_out: true,
|
|
1342
1551
|
elapsed_minutes: 15,
|
|
@@ -1347,7 +1556,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1347
1556
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1348
1557
|
};
|
|
1349
1558
|
}));
|
|
1350
|
-
// Tool
|
|
1559
|
+
// Tool 8: orchestration-mode
|
|
1351
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.", {
|
|
1352
1561
|
enabled: z.boolean().optional(),
|
|
1353
1562
|
}, withMaintenance(async (params) => {
|