@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.
@@ -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" || agent.status === "stalled";
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.4",
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" || agent.status === "stalled" || agent.status === "finished") &&
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: send_message
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" || agent.status === "stalled" || agent.status === "finished") &&
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 5: list_agents
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 6: wait
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
- if (unreported.length > 0) {
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 payload = { finished: unreported.map(buildFinishedEntry) };
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
- if (unreported.length > 0) {
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 payload = { finished: unreported.map(buildFinishedEntry) };
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" || a.status === "stalled");
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 7: orchestration-mode
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) => {