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