@heretyc/subagent-mcp 2.12.3 → 2.12.5-beta.0

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