@heretyc/subagent-mcp 2.12.4 → 2.12.5-beta.1
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 +386 -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 +246 -26
- package/dist/pending-permissions.js +200 -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 +25 -2
- package/dist/wait-helpers.js +3 -0
- package/package.json +69 -69
- package/scripts/postinstall.mjs +102 -102
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// subagent-mcp - Global Subagent MCP Config
|
|
2
|
+
// ------------------------------------------------------------------
|
|
3
|
+
// SOLE source of truth for machine-wide subagent-mcp defaults that must be
|
|
4
|
+
// available beside the compiled server.
|
|
5
|
+
//
|
|
6
|
+
// globalConcurrentSubagents controls how many subagents may be ALIVE AT ONCE
|
|
7
|
+
// across EVERY session, process, and user on this machine. There is NO
|
|
8
|
+
// environment-variable override for the cap.
|
|
9
|
+
//
|
|
10
|
+
// RE-READ on every launch_agent call - edits take effect immediately, no
|
|
11
|
+
// server restart required.
|
|
12
|
+
//
|
|
13
|
+
// Cap value rules:
|
|
14
|
+
// - missing / unset / non-integer / 0 / negative -> reset to default 20
|
|
15
|
+
// - 1 through 9 -> forced UP to minimum 10
|
|
16
|
+
// - 10 or greater -> used as-is
|
|
17
|
+
//
|
|
18
|
+
// checkForUpdates controls the silent npmjs update check started when the MCP
|
|
19
|
+
// server connects. Default true. Set to false to skip the registry fetch and
|
|
20
|
+
// suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.
|
|
21
|
+
//
|
|
22
|
+
// permissionsCeiling controls launch-time permissions posture:
|
|
23
|
+
// - auto (default): shared engine gates unsafe/residue actions
|
|
24
|
+
// - manual: human approval for residue while still auto-denying DANGER
|
|
25
|
+
// - yolo: preserve the historical bypass/danger-full-access path, except
|
|
26
|
+
// for non-bypassable config-file self-protection
|
|
27
|
+
//
|
|
28
|
+
// escalation applies only in auto mode. irreversible-only routes irreversible
|
|
29
|
+
// NEUTRAL residue to a human; off leaves residue to orchestrator judgment.
|
|
30
|
+
//
|
|
31
|
+
// strictReadParity controls logging only. Unparseable Codex approval payloads
|
|
32
|
+
// always fail closed to ask; warn logs the construct, off silences that log.
|
|
33
|
+
{
|
|
34
|
+
"globalConcurrentSubagents": 20,
|
|
35
|
+
"checkForUpdates": true,
|
|
36
|
+
"permissionsCeiling": "auto",
|
|
37
|
+
"escalation": "irreversible-only",
|
|
38
|
+
"strictReadParity": "warn"
|
|
39
|
+
}
|
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";
|
|
15
|
-
import { computeStatusTransition, buildLivenessFields, } from "./status-helpers.js";
|
|
14
|
+
import { formatLocalIso, selectUnreported, selectUnreportedPermissionRequested, } from "./wait-helpers.js";
|
|
15
|
+
import { computeStatusTransition, buildLivenessFields, reconcilePermissionStatus, } 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,66 @@ 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
|
+
// Reconcile one agent's live status against its current pending-permission
|
|
73
|
+
// depth. Used by the live queue listener AND at registration time (agents.set)
|
|
74
|
+
// to recover a park whose queue event fired before the agent was registered —
|
|
75
|
+
// the Codex approval race, where approvals arrive during driver.start() inside
|
|
76
|
+
// the spawn-grace window, ahead of agents.set. Idempotent and no-op unless the
|
|
77
|
+
// pure rule says the status must change.
|
|
78
|
+
function reconcileAgentPermissionStatus(agent) {
|
|
79
|
+
if (agent.exitCode !== null)
|
|
80
|
+
return;
|
|
81
|
+
const { status, changed } = reconcilePermissionStatus(agent.status, pendingPermissionManager.pendingCount(agent.id));
|
|
82
|
+
if (!changed)
|
|
83
|
+
return;
|
|
84
|
+
agent.status = status;
|
|
85
|
+
agent.waitReported = false;
|
|
86
|
+
if (status === "processing")
|
|
87
|
+
agent.lastActivity = Date.now();
|
|
88
|
+
updateSlotMetadata(agent);
|
|
89
|
+
}
|
|
90
|
+
pendingPermissionManager.onAgentQueueChange((agentId) => {
|
|
91
|
+
const agent = agents.get(agentId);
|
|
92
|
+
if (!agent)
|
|
93
|
+
return;
|
|
94
|
+
reconcileAgentPermissionStatus(agent);
|
|
95
|
+
});
|
|
35
96
|
// Post-spawn grace window (ms). A provider driver that exits within this window
|
|
36
97
|
// after a successful driver start never launched (not logged in, expired auth, instant
|
|
37
98
|
// crash) — the attempt loop silently advances instead of falsely reporting
|
|
@@ -96,7 +157,9 @@ function slotHeartbeatIntervalMs() {
|
|
|
96
157
|
return Math.max(1000, Math.min(30_000, Math.floor(zombieLiveIdleMs() / 3)));
|
|
97
158
|
}
|
|
98
159
|
function isLiveAgent(agent) {
|
|
99
|
-
return agent.status === "processing" ||
|
|
160
|
+
return (agent.status === "processing" ||
|
|
161
|
+
agent.status === "permission_requested" ||
|
|
162
|
+
agent.status === "stalled");
|
|
100
163
|
}
|
|
101
164
|
function isSameOwnerSlot(agent, slotMeta) {
|
|
102
165
|
if (!slotMeta)
|
|
@@ -172,6 +235,7 @@ function scheduleZombieForceKill(agent) {
|
|
|
172
235
|
timer.unref();
|
|
173
236
|
}
|
|
174
237
|
function markZombieKilled(agent, reason, now) {
|
|
238
|
+
void pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
175
239
|
agent.status = "zombie_killed";
|
|
176
240
|
agent.exitCode = agent.exitCode ?? -1;
|
|
177
241
|
agent.exitedAt = agent.exitedAt ?? now;
|
|
@@ -210,6 +274,7 @@ function applyZombieRecord(record, now) {
|
|
|
210
274
|
const agent = agents.get(record.agent_id);
|
|
211
275
|
if (!agent || agent.status === "zombie_killed")
|
|
212
276
|
return null;
|
|
277
|
+
void pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
213
278
|
agent.status = "zombie_killed";
|
|
214
279
|
agent.exitCode = agent.exitCode ?? -1;
|
|
215
280
|
agent.exitedAt = agent.exitedAt ?? now;
|
|
@@ -394,6 +459,15 @@ function cleanupUcSettings(agentState) {
|
|
|
394
459
|
catch { }
|
|
395
460
|
agentState.ucSettingsPath = undefined;
|
|
396
461
|
}
|
|
462
|
+
if (agentState.ucSettingsDir) {
|
|
463
|
+
try {
|
|
464
|
+
if (existsSync(agentState.ucSettingsDir)) {
|
|
465
|
+
rmSync(agentState.ucSettingsDir, { recursive: true, force: true });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch { }
|
|
469
|
+
agentState.ucSettingsDir = undefined;
|
|
470
|
+
}
|
|
397
471
|
}
|
|
398
472
|
// Synchronously reconcile a single agent's status against the pure transition
|
|
399
473
|
// helper. Folds the live process exitCode into AgentState first so an already-
|
|
@@ -442,7 +516,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
|
|
|
442
516
|
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
517
|
const server = new McpServer({
|
|
444
518
|
name: "subagent-mcp",
|
|
445
|
-
version: "2.12.
|
|
519
|
+
version: "2.12.5-beta.1",
|
|
446
520
|
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
521
|
}, {
|
|
448
522
|
instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
|
|
@@ -452,7 +526,7 @@ const server = new McpServer({
|
|
|
452
526
|
// Best-effort removal of a candidate's temp ultracode settings file after a
|
|
453
527
|
// LAUNCH-TIME failure (the agentState is never registered, so the close handler
|
|
454
528
|
// will not run cleanupUcSettings for it).
|
|
455
|
-
function cleanupUcSettingsPath(ucSettingsPath) {
|
|
529
|
+
function cleanupUcSettingsPath(ucSettingsPath, ucSettingsDir) {
|
|
456
530
|
if (!ucSettingsPath)
|
|
457
531
|
return;
|
|
458
532
|
try {
|
|
@@ -460,6 +534,13 @@ function cleanupUcSettingsPath(ucSettingsPath) {
|
|
|
460
534
|
unlinkSync(ucSettingsPath);
|
|
461
535
|
}
|
|
462
536
|
catch { }
|
|
537
|
+
if (!ucSettingsDir)
|
|
538
|
+
return;
|
|
539
|
+
try {
|
|
540
|
+
if (existsSync(ucSettingsDir))
|
|
541
|
+
rmSync(ucSettingsDir, { recursive: true, force: true });
|
|
542
|
+
}
|
|
543
|
+
catch { }
|
|
463
544
|
}
|
|
464
545
|
// Attempt to spawn + register a single candidate. Resolves to the agent_id on a
|
|
465
546
|
// successful driver start, or a launch-time failure reason string (never throws/rejects).
|
|
@@ -472,14 +553,15 @@ function cleanupUcSettingsPath(ucSettingsPath) {
|
|
|
472
553
|
// 'error' handler stays attached so a LATE spawn error can never crash the
|
|
473
554
|
// process. Any launch-time failure cleans up and is reported so the attempt loop
|
|
474
555
|
// silently advances to the next candidate.
|
|
475
|
-
async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetInfo) {
|
|
556
|
+
async function tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapshot, routingTier, rulesetInfo) {
|
|
476
557
|
// Build the command. haiku ignores effort; pass "high" placeholder for the
|
|
477
558
|
// "none" sentinel (buildCommand drops it for haiku anyway).
|
|
478
559
|
const effortForBuild = candidate.effort === "none" ? "high" : candidate.effort;
|
|
560
|
+
const agentId = randomUUID();
|
|
479
561
|
let buildResult;
|
|
480
562
|
let cmd;
|
|
481
563
|
try {
|
|
482
|
-
buildResult = buildCommand(candidate.provider, candidate.model, effortForBuild, agentCwd);
|
|
564
|
+
buildResult = buildCommand(candidate.provider, candidate.model, effortForBuild, agentCwd, agentId);
|
|
483
565
|
cmd = resolveExe(candidate.provider);
|
|
484
566
|
}
|
|
485
567
|
catch (e) {
|
|
@@ -489,7 +571,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
489
571
|
// Fast-fail absolute paths only. Bare names intentionally rely on PATH; spawn
|
|
490
572
|
// below resolves them and reports ENOENT/EACCES through the same failure path.
|
|
491
573
|
if (isAbsolute(cmd) && !existsSync(cmd)) {
|
|
492
|
-
cleanupUcSettingsPath(buildResult.ucSettingsPath);
|
|
574
|
+
cleanupUcSettingsPath(buildResult.ucSettingsPath, buildResult.ucSettingsDir);
|
|
493
575
|
return { reason: `CLI executable not found: ${cmd}`, failure_type: "permanent" };
|
|
494
576
|
}
|
|
495
577
|
let driver;
|
|
@@ -503,11 +585,13 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
503
585
|
model: candidate.model,
|
|
504
586
|
effort: candidate.effort,
|
|
505
587
|
ucSettingsPath: buildResult.ucSettingsPath,
|
|
588
|
+
ucSettingsDir: buildResult.ucSettingsDir,
|
|
589
|
+
agentId,
|
|
506
590
|
});
|
|
507
591
|
}
|
|
508
592
|
catch (error) {
|
|
509
593
|
// Synchronous spawn throw (rare) — clean up and report as a launch failure.
|
|
510
|
-
cleanupUcSettingsPath(buildResult.ucSettingsPath);
|
|
594
|
+
cleanupUcSettingsPath(buildResult.ucSettingsPath, buildResult.ucSettingsDir);
|
|
511
595
|
const reason = error instanceof Error ? error.message : String(error);
|
|
512
596
|
return { reason, failure_type: classifyFailureReason(reason, "") };
|
|
513
597
|
}
|
|
@@ -547,14 +631,13 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
547
631
|
driver.kill();
|
|
548
632
|
}
|
|
549
633
|
catch { }
|
|
550
|
-
cleanupUcSettingsPath(buildResult.ucSettingsPath);
|
|
634
|
+
cleanupUcSettingsPath(buildResult.ucSettingsPath, buildResult.ucSettingsDir);
|
|
551
635
|
const reason = err instanceof Error ? err.message : String(err);
|
|
552
636
|
return { reason, failure_type: classifyFailureReason(reason, "") };
|
|
553
637
|
}
|
|
554
638
|
// Spawn succeeded. Register the agent exactly as before. Keep a persistent
|
|
555
639
|
// 'error' handler so a LATE spawn error never crashes the process; fold it
|
|
556
640
|
// into stderr rather than throwing.
|
|
557
|
-
const agentId = randomUUID();
|
|
558
641
|
const now = Date.now();
|
|
559
642
|
const agentState = {
|
|
560
643
|
id: agentId,
|
|
@@ -580,7 +663,9 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
580
663
|
startedAt: now,
|
|
581
664
|
lastActivity: now,
|
|
582
665
|
cwd: agentCwd,
|
|
666
|
+
permissionSnapshot,
|
|
583
667
|
ucSettingsPath: buildResult.ucSettingsPath,
|
|
668
|
+
ucSettingsDir: buildResult.ucSettingsDir,
|
|
584
669
|
waitReported: false,
|
|
585
670
|
visibleStream: [],
|
|
586
671
|
streamBuf: "",
|
|
@@ -635,6 +720,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
635
720
|
});
|
|
636
721
|
}
|
|
637
722
|
childProcess.on("close", (code) => {
|
|
723
|
+
void pendingPermissionManager.closeAgent(agentState.id, "agent process exited while permission request was pending");
|
|
638
724
|
// Flush any buffered trailing stdout line (final event may arrive without a
|
|
639
725
|
// terminating newline) so its visible item is not lost.
|
|
640
726
|
if (agentState.streamBuf) {
|
|
@@ -769,6 +855,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
769
855
|
const startedBeforeStartError = await readStartedBoundary();
|
|
770
856
|
if (startedBeforeStartError) {
|
|
771
857
|
agents.set(agentId, agentState);
|
|
858
|
+
reconcileAgentPermissionStatus(agentState);
|
|
772
859
|
return { agentId };
|
|
773
860
|
}
|
|
774
861
|
// Lived past the grace window but the startup write failed: the child is
|
|
@@ -786,6 +873,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
786
873
|
}
|
|
787
874
|
}
|
|
788
875
|
agents.set(agentId, agentState);
|
|
876
|
+
reconcileAgentPermissionStatus(agentState);
|
|
789
877
|
return { agentId };
|
|
790
878
|
}
|
|
791
879
|
// Order-sensitive (provider, model, effort) list equality. Detects whether the
|
|
@@ -812,6 +900,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
812
900
|
// mutates the body). This is what makes the child first-line exemption fire.
|
|
813
901
|
const prompt = ensureParentMarker(params.prompt);
|
|
814
902
|
const agentCwd = params.cwd || process.cwd();
|
|
903
|
+
const permissionSnapshot = buildPermissionSnapshot(agentCwd);
|
|
815
904
|
// 1-5. Param-presence validation (zod already constrains task_category, but
|
|
816
905
|
// hard-validate so the spec error text — valid list + hints, and the
|
|
817
906
|
// effort-before-model ordering — is what the caller sees). Pure,
|
|
@@ -914,7 +1003,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
914
1003
|
let launched = false;
|
|
915
1004
|
try {
|
|
916
1005
|
for (const candidate of candidates) {
|
|
917
|
-
const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
|
|
1006
|
+
const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapshot, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
|
|
918
1007
|
? { applied: true, originalSelection: rulesetOriginalSelection }
|
|
919
1008
|
: undefined);
|
|
920
1009
|
if ("agentId" in outcome) {
|
|
@@ -955,6 +1044,15 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
955
1044
|
model: candidate.model,
|
|
956
1045
|
effort: candidate.effort,
|
|
957
1046
|
task_category,
|
|
1047
|
+
permissions_applied: {
|
|
1048
|
+
ceiling: permissionSnapshot.ceiling,
|
|
1049
|
+
escalation: permissionSnapshot.escalation,
|
|
1050
|
+
allow_count: permissionSnapshot.rules.allow?.length ?? 0,
|
|
1051
|
+
ask_count: permissionSnapshot.rules.ask?.length ?? 0,
|
|
1052
|
+
deny_count: permissionSnapshot.rules.deny?.length ?? 0,
|
|
1053
|
+
additional_directories_count: permissionSnapshot.additionalDirectories?.length ?? 0,
|
|
1054
|
+
repo_config_changed_since_first_seen: permissionSnapshot.repoConfigChangedSinceFirstSeen ?? false,
|
|
1055
|
+
},
|
|
958
1056
|
...(rulesetApplied
|
|
959
1057
|
? {
|
|
960
1058
|
ruleset_applied: true,
|
|
@@ -1056,6 +1154,19 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
1056
1154
|
last_activity: agent.lastActivity,
|
|
1057
1155
|
cwd: agent.cwd,
|
|
1058
1156
|
...liveness,
|
|
1157
|
+
...(pendingPermissionManager.pendingCount(agent.id) > 0
|
|
1158
|
+
? {
|
|
1159
|
+
pending_permissions: pendingPermissionManager
|
|
1160
|
+
.pendingForAgent(agent.id)
|
|
1161
|
+
.map((p) => pendingPermissionSummary(p, now)),
|
|
1162
|
+
}
|
|
1163
|
+
: {}),
|
|
1164
|
+
...(isStalePermissive(agent)
|
|
1165
|
+
? {
|
|
1166
|
+
stale_permissive: true,
|
|
1167
|
+
stale_permissive_hint: "agent launched under a more permissive ceiling than current config; consider kill_agent",
|
|
1168
|
+
}
|
|
1169
|
+
: {}),
|
|
1059
1170
|
...(agent.rulesetApplied
|
|
1060
1171
|
? {
|
|
1061
1172
|
ruleset_applied: true,
|
|
@@ -1104,7 +1215,10 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1104
1215
|
// Kill applies to ALL live driver states (processing, stalled, or finished
|
|
1105
1216
|
// current turn with an open interactive session). Closed terminal agents are
|
|
1106
1217
|
// a no-op.
|
|
1107
|
-
const isLive = (agent.status === "processing" ||
|
|
1218
|
+
const isLive = (agent.status === "processing" ||
|
|
1219
|
+
agent.status === "permission_requested" ||
|
|
1220
|
+
agent.status === "stalled" ||
|
|
1221
|
+
agent.status === "finished") &&
|
|
1108
1222
|
!agent.driver.closed;
|
|
1109
1223
|
if (!isLive) {
|
|
1110
1224
|
return {
|
|
@@ -1124,6 +1238,7 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1124
1238
|
// Immediately force-kill the managed process tree — no graceful SIGTERM
|
|
1125
1239
|
// grace period. On Windows, taskkill /t /f tears down the whole tree; on
|
|
1126
1240
|
// POSIX, SIGKILL the process (close handler records the real exit code).
|
|
1241
|
+
await pendingPermissionManager.closeAgent(agent.id, "agent stopped by operator");
|
|
1127
1242
|
agent.status = "stopped";
|
|
1128
1243
|
agent.driver.kill();
|
|
1129
1244
|
releaseSlot(agent.slotPath ?? null);
|
|
@@ -1165,7 +1280,52 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
|
|
|
1165
1280
|
};
|
|
1166
1281
|
}
|
|
1167
1282
|
}));
|
|
1168
|
-
// Tool 4:
|
|
1283
|
+
// Tool 4: respond_permission (parents only; children cannot approve other children)
|
|
1284
|
+
if (process.env.SUBAGENT_MCP_SUBAGENT !== "1") {
|
|
1285
|
+
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.", {
|
|
1286
|
+
agent_id: z.string(),
|
|
1287
|
+
request_id: z.string().optional(),
|
|
1288
|
+
decision: z.enum(["allow", "deny"]),
|
|
1289
|
+
reason: z.string().optional(),
|
|
1290
|
+
}, withMaintenance(async (params) => {
|
|
1291
|
+
const agent = agents.get(params.agent_id);
|
|
1292
|
+
if (!agent) {
|
|
1293
|
+
return {
|
|
1294
|
+
content: [{ type: "text", text: `Error: Agent ${params.agent_id} not found` }],
|
|
1295
|
+
isError: true,
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const answered = await pendingPermissionManager.respond(agent.id, params.request_id, params.decision, params.reason);
|
|
1300
|
+
return {
|
|
1301
|
+
content: [
|
|
1302
|
+
{
|
|
1303
|
+
type: "text",
|
|
1304
|
+
text: JSON.stringify({
|
|
1305
|
+
agent_id: agent.id,
|
|
1306
|
+
request_id: answered.request_id,
|
|
1307
|
+
decision: answered.answer,
|
|
1308
|
+
status: agent.status,
|
|
1309
|
+
pending_permission_count: pendingPermissionManager.pendingCount(agent.id),
|
|
1310
|
+
}),
|
|
1311
|
+
},
|
|
1312
|
+
],
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
catch (error) {
|
|
1316
|
+
return {
|
|
1317
|
+
content: [
|
|
1318
|
+
{
|
|
1319
|
+
type: "text",
|
|
1320
|
+
text: `Error responding to permission request: ${error instanceof Error ? error.message : String(error)}`,
|
|
1321
|
+
},
|
|
1322
|
+
],
|
|
1323
|
+
isError: true,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
}));
|
|
1327
|
+
}
|
|
1328
|
+
// Tool 5: send_message
|
|
1169
1329
|
server.tool("send_message", "Enqueue a user message for an open interactive agent session. Observe output with poll_agent or wait.", {
|
|
1170
1330
|
agent_id: z.string(),
|
|
1171
1331
|
message: z.string().min(1),
|
|
@@ -1182,7 +1342,10 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1182
1342
|
isError: true,
|
|
1183
1343
|
};
|
|
1184
1344
|
}
|
|
1185
|
-
const isLive = (agent.status === "processing" ||
|
|
1345
|
+
const isLive = (agent.status === "processing" ||
|
|
1346
|
+
agent.status === "permission_requested" ||
|
|
1347
|
+
agent.status === "stalled" ||
|
|
1348
|
+
agent.status === "finished") &&
|
|
1186
1349
|
!agent.driver.closed;
|
|
1187
1350
|
if (!isLive) {
|
|
1188
1351
|
return {
|
|
@@ -1195,6 +1358,19 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1195
1358
|
isError: true,
|
|
1196
1359
|
};
|
|
1197
1360
|
}
|
|
1361
|
+
const pendingCount = pendingPermissionManager.pendingCount(agent.id);
|
|
1362
|
+
if (pendingCount > 0) {
|
|
1363
|
+
return {
|
|
1364
|
+
content: [
|
|
1365
|
+
{
|
|
1366
|
+
type: "text",
|
|
1367
|
+
text: `Error: agent has ${pendingCount} pending permission request(s); ` +
|
|
1368
|
+
`call respond_permission before sending further messages`,
|
|
1369
|
+
},
|
|
1370
|
+
],
|
|
1371
|
+
isError: true,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1198
1374
|
try {
|
|
1199
1375
|
await agent.driver.send(params.message);
|
|
1200
1376
|
const now = Date.now();
|
|
@@ -1233,7 +1409,7 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1233
1409
|
};
|
|
1234
1410
|
}
|
|
1235
1411
|
}));
|
|
1236
|
-
// Tool
|
|
1412
|
+
// Tool 6: list_agents
|
|
1237
1413
|
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
1414
|
const now = Date.now();
|
|
1239
1415
|
const agentList = Array.from(agents.values()).map((agent) => {
|
|
@@ -1250,6 +1426,8 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
|
|
|
1250
1426
|
started_at: agent.startedAt,
|
|
1251
1427
|
last_activity: agent.lastActivity,
|
|
1252
1428
|
cwd_basename: basename(agent.cwd),
|
|
1429
|
+
pending_permission_count: pendingPermissionManager.pendingCount(agent.id),
|
|
1430
|
+
...(isStalePermissive(agent) ? { stale_permissive: true } : {}),
|
|
1253
1431
|
...buildLivenessFields(agent.status, agent.exitCode, agent.lastActivity, now, false),
|
|
1254
1432
|
};
|
|
1255
1433
|
});
|
|
@@ -1262,7 +1440,7 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
|
|
|
1262
1440
|
],
|
|
1263
1441
|
};
|
|
1264
1442
|
}, { includeZombieReport: true }));
|
|
1265
|
-
// Tool
|
|
1443
|
+
// Tool 7: wait
|
|
1266
1444
|
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
1445
|
verbose: z.boolean().optional().default(false),
|
|
1268
1446
|
}, withMaintenance(async (params, zombieRecords) => {
|
|
@@ -1292,15 +1470,43 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1292
1470
|
started_at_local: formatLocalIso(a.startedAt),
|
|
1293
1471
|
last_activity_local: formatLocalIso(a.lastActivity),
|
|
1294
1472
|
elapsed_ms: now - a.startedAt,
|
|
1473
|
+
...(isStalePermissive(a) ? { stale_permissive: true } : {}),
|
|
1474
|
+
...(a.status === "permission_requested"
|
|
1475
|
+
? {
|
|
1476
|
+
pending_permissions: pendingPermissionManager
|
|
1477
|
+
.pendingForAgent(a.id)
|
|
1478
|
+
.map((p) => pendingPermissionSummary(p, now)),
|
|
1479
|
+
}
|
|
1480
|
+
: {}),
|
|
1481
|
+
});
|
|
1482
|
+
const buildPermissionRequestedEntry = (a, now) => ({
|
|
1483
|
+
id: a.id,
|
|
1484
|
+
provider: a.provider,
|
|
1485
|
+
model: a.model,
|
|
1486
|
+
status: a.status,
|
|
1487
|
+
...(isStalePermissive(a) ? { stale_permissive: true } : {}),
|
|
1488
|
+
pending_permissions: pendingPermissionManager
|
|
1489
|
+
.pendingForAgent(a.id)
|
|
1490
|
+
.map((p) => pendingPermissionSummary(p, now)),
|
|
1295
1491
|
});
|
|
1296
1492
|
// Step 1: collect already-terminal unreported agents
|
|
1297
1493
|
const allAgents = Array.from(agents.values());
|
|
1298
1494
|
let unreported = selectUnreported(allAgents);
|
|
1299
|
-
|
|
1495
|
+
let unreportedPermissionRequested = selectUnreportedPermissionRequested(allAgents);
|
|
1496
|
+
if (unreported.length > 0 || unreportedPermissionRequested.length > 0) {
|
|
1300
1497
|
// Mark reported synchronously before building return (single-threaded JS → atomic)
|
|
1301
1498
|
for (const a of unreported)
|
|
1302
1499
|
a.waitReported = true;
|
|
1303
|
-
const
|
|
1500
|
+
for (const a of unreportedPermissionRequested)
|
|
1501
|
+
a.waitReported = true;
|
|
1502
|
+
const payload = {
|
|
1503
|
+
...(unreported.length > 0 ? { finished: unreported.map(buildFinishedEntry) } : {}),
|
|
1504
|
+
...(unreportedPermissionRequested.length > 0
|
|
1505
|
+
? {
|
|
1506
|
+
permission_requested: unreportedPermissionRequested.map((a) => buildPermissionRequestedEntry(a, Date.now())),
|
|
1507
|
+
}
|
|
1508
|
+
: {}),
|
|
1509
|
+
};
|
|
1304
1510
|
return {
|
|
1305
1511
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1306
1512
|
};
|
|
@@ -1309,6 +1515,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1309
1515
|
// `stalled` is a LIVE state — it keeps the wait pending, it never ends it.
|
|
1310
1516
|
const TERMINAL_SET = new Set(["finished", "errored", "stopped", "zombie_killed"]);
|
|
1311
1517
|
const hasPending = Array.from(agents.values()).some((a) => a.status === "processing" ||
|
|
1518
|
+
a.status === "permission_requested" ||
|
|
1312
1519
|
a.status === "stalled" ||
|
|
1313
1520
|
(TERMINAL_SET.has(a.status) && a.exitedAt === null));
|
|
1314
1521
|
if (!hasPending) {
|
|
@@ -1323,12 +1530,23 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1323
1530
|
// Step 3: block-poll until a terminal agent appears or deadline passes
|
|
1324
1531
|
while (Date.now() < deadline) {
|
|
1325
1532
|
await sleep(250);
|
|
1326
|
-
unreported = selectUnreported(Array.from(agents.values()));
|
|
1327
1533
|
zombieRecords.push(...runToolMaintenance());
|
|
1328
|
-
|
|
1534
|
+
const loopAgents = Array.from(agents.values());
|
|
1535
|
+
unreported = selectUnreported(loopAgents);
|
|
1536
|
+
unreportedPermissionRequested = selectUnreportedPermissionRequested(loopAgents);
|
|
1537
|
+
if (unreported.length > 0 || unreportedPermissionRequested.length > 0) {
|
|
1329
1538
|
for (const a of unreported)
|
|
1330
1539
|
a.waitReported = true;
|
|
1331
|
-
const
|
|
1540
|
+
for (const a of unreportedPermissionRequested)
|
|
1541
|
+
a.waitReported = true;
|
|
1542
|
+
const payload = {
|
|
1543
|
+
...(unreported.length > 0 ? { finished: unreported.map(buildFinishedEntry) } : {}),
|
|
1544
|
+
...(unreportedPermissionRequested.length > 0
|
|
1545
|
+
? {
|
|
1546
|
+
permission_requested: unreportedPermissionRequested.map((a) => buildPermissionRequestedEntry(a, Date.now())),
|
|
1547
|
+
}
|
|
1548
|
+
: {}),
|
|
1549
|
+
};
|
|
1332
1550
|
return {
|
|
1333
1551
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1334
1552
|
};
|
|
@@ -1336,7 +1554,9 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1336
1554
|
}
|
|
1337
1555
|
// Step 4: timeout — return still-running jobs
|
|
1338
1556
|
const now = Date.now();
|
|
1339
|
-
const stillRunning = Array.from(agents.values()).filter((a) => a.status === "processing" ||
|
|
1557
|
+
const stillRunning = Array.from(agents.values()).filter((a) => a.status === "processing" ||
|
|
1558
|
+
a.status === "permission_requested" ||
|
|
1559
|
+
a.status === "stalled");
|
|
1340
1560
|
const payload = {
|
|
1341
1561
|
timed_out: true,
|
|
1342
1562
|
elapsed_minutes: 15,
|
|
@@ -1347,7 +1567,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1347
1567
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1348
1568
|
};
|
|
1349
1569
|
}));
|
|
1350
|
-
// Tool
|
|
1570
|
+
// Tool 8: orchestration-mode
|
|
1351
1571
|
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
1572
|
enabled: z.boolean().optional(),
|
|
1353
1573
|
}, withMaintenance(async (params) => {
|