@heretyc/subagent-mcp 2.10.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { consumeStreamChunk, flushStream, isTurnCompletedLine, retainLastN, } fr
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, defaultConfigPath, globalCapMessage, readGlobalCap, releaseSlot, reserveSlot, } 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, readGlobalCap, releaseSlot, reserveSlot, slotDir, writeSlotMetadata, } from "./concurrency.js";
22
22
  import * as orchestrationMarker from "./orchestration/marker.js";
23
23
  import * as modelMode from "./orchestration/model-mode.js";
24
24
  import { startLivenessHeartbeat } from "./orchestration/liveness.js";
@@ -54,6 +54,189 @@ const TASK_CATEGORY_GLOSS = "REQUIRED. Task shape -> routing category (server pi
54
54
  function errorResult(text) {
55
55
  return { content: [{ type: "text", text }], isError: true };
56
56
  }
57
+ function envDuration(name, fallback) {
58
+ const raw = process.env[name];
59
+ if (raw === undefined || raw === "")
60
+ return fallback;
61
+ const parsed = Number.parseInt(raw, 10);
62
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
63
+ }
64
+ function zombieLiveIdleMs() {
65
+ return envDuration("SUBAGENT_ZOMBIE_LIVE_IDLE_MS", ZOMBIE_LIVE_IDLE_MS);
66
+ }
67
+ function zombieTerminalIdleMs() {
68
+ return envDuration("SUBAGENT_ZOMBIE_TERMINAL_IDLE_MS", ZOMBIE_TERMINAL_IDLE_MS);
69
+ }
70
+ function zombieForceGraceMs() {
71
+ return envDuration("SUBAGENT_ZOMBIE_FORCE_GRACE_MS", ZOMBIE_FORCE_GRACE_MS);
72
+ }
73
+ function zombieReport(records) {
74
+ const ids = Array.from(new Set(records.map((r) => r.agent_id))).filter(Boolean);
75
+ return ids.length > 0 ? `zombies: ${ids.join(",")}` : undefined;
76
+ }
77
+ function withZombieReport(result, records) {
78
+ const report = zombieReport(records);
79
+ if (!report || !result.content?.[0] || result.content[0].type !== "text")
80
+ return result;
81
+ const text = result.content[0].text;
82
+ try {
83
+ const parsed = JSON.parse(text);
84
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
85
+ parsed.zombie_report = report;
86
+ result.content[0].text = JSON.stringify(parsed, null, text.includes("\n") ? 2 : undefined);
87
+ return result;
88
+ }
89
+ }
90
+ catch { }
91
+ result.content[0].text = `${text}\n${report}`;
92
+ return result;
93
+ }
94
+ function updateSlotMetadata(agent) {
95
+ if (!agent.slotPath)
96
+ return;
97
+ writeSlotMetadata(agent.slotPath, {
98
+ agent_id: agent.id,
99
+ server_pid: process.pid,
100
+ child_pid: agent.process.pid ?? null,
101
+ cwd: agent.cwd,
102
+ started_at: new Date(agent.startedAt).toISOString(),
103
+ started_at_ms: agent.startedAt,
104
+ last_activity_ms: agent.lastActivity,
105
+ status: agent.status,
106
+ });
107
+ }
108
+ function spawnProcessTreeKill(pid, force) {
109
+ const commands = buildProcessTreeKillCommands(pid);
110
+ const command = force ? commands.force : commands.graceful;
111
+ try {
112
+ const child = spawn(command.command, command.args, {
113
+ stdio: "ignore",
114
+ windowsHide: true,
115
+ detached: false,
116
+ });
117
+ child.unref();
118
+ }
119
+ catch { }
120
+ }
121
+ function scheduleZombieForceKill(agent) {
122
+ const pid = agent.process.pid;
123
+ if (!pid || agent.driver.closed)
124
+ return;
125
+ const timer = setTimeout(() => {
126
+ if (!agent.driver.closed && agent.status === "zombie_killed") {
127
+ try {
128
+ agent.driver.kill();
129
+ }
130
+ catch { }
131
+ spawnProcessTreeKill(pid, true);
132
+ }
133
+ }, zombieForceGraceMs());
134
+ timer.unref();
135
+ }
136
+ function markZombieKilled(agent, reason, now) {
137
+ agent.status = "zombie_killed";
138
+ agent.exitCode = agent.exitCode ?? -1;
139
+ agent.exitedAt = agent.exitedAt ?? now;
140
+ agent.waitReported = false;
141
+ releaseSlot(agent.slotPath ?? null);
142
+ const slotPath = agent.slotPath ?? "";
143
+ agent.slotPath = null;
144
+ const pid = agent.process.pid ?? null;
145
+ const record = {
146
+ kind: "zombie_killed",
147
+ agent_id: agent.id,
148
+ child_pid: pid,
149
+ server_pid: process.pid,
150
+ slot_path: slotPath,
151
+ reason,
152
+ detected_at_ms: now,
153
+ last_activity_ms: agent.lastActivity,
154
+ message: `zombies: culled ${agent.id}`,
155
+ };
156
+ if (pid && !agent.driver.closed) {
157
+ if (reason === "terminal_but_alive") {
158
+ try {
159
+ agent.driver.kill();
160
+ }
161
+ catch { }
162
+ spawnProcessTreeKill(pid, true);
163
+ }
164
+ else {
165
+ spawnProcessTreeKill(pid, false);
166
+ scheduleZombieForceKill(agent);
167
+ }
168
+ }
169
+ return record;
170
+ }
171
+ function applyZombieRecord(record, now) {
172
+ const agent = agents.get(record.agent_id);
173
+ if (!agent || agent.status === "zombie_killed")
174
+ return null;
175
+ agent.status = "zombie_killed";
176
+ agent.exitCode = agent.exitCode ?? -1;
177
+ agent.exitedAt = agent.exitedAt ?? now;
178
+ agent.waitReported = false;
179
+ releaseSlot(agent.slotPath ?? null);
180
+ agent.slotPath = null;
181
+ try {
182
+ if (!agent.driver.closed)
183
+ agent.driver.kill();
184
+ }
185
+ catch { }
186
+ return { ...record, detected_at_ms: record.detected_at_ms || now };
187
+ }
188
+ function runToolMaintenance() {
189
+ const now = Date.now();
190
+ const records = [];
191
+ const applied = new Set();
192
+ for (const record of [...drainZombieIntents(slotDir()), ...drainZombieReports(slotDir())]) {
193
+ if (applied.has(record.agent_id))
194
+ continue;
195
+ const appliedRecord = applyZombieRecord(record, now);
196
+ if (appliedRecord) {
197
+ records.push(appliedRecord);
198
+ applied.add(record.agent_id);
199
+ }
200
+ }
201
+ for (const agent of agents.values()) {
202
+ if (agent.status === "zombie_killed")
203
+ continue;
204
+ reconcileAgent(agent, now);
205
+ const slotMeta = agent.slotPath ? readSlotMetadata(agent.slotPath) : null;
206
+ if (slotMeta?.last_activity_ms !== null && slotMeta?.last_activity_ms !== undefined) {
207
+ agent.lastActivity = Math.min(agent.lastActivity, slotMeta.last_activity_ms);
208
+ }
209
+ const live = agent.status === "processing" || agent.status === "stalled";
210
+ if (live && now - agent.lastActivity > zombieLiveIdleMs()) {
211
+ const record = markZombieKilled(agent, "stale_live", now);
212
+ records.push(record);
213
+ applied.add(agent.id);
214
+ continue;
215
+ }
216
+ const terminalButAlive = (agent.status === "finished" || agent.status === "errored" || agent.status === "stopped") &&
217
+ !agent.driver.closed &&
218
+ agent.exitedAt !== null &&
219
+ now - agent.exitedAt > zombieTerminalIdleMs();
220
+ if (terminalButAlive) {
221
+ const record = markZombieKilled(agent, "terminal_but_alive", now);
222
+ records.push(record);
223
+ applied.add(agent.id);
224
+ continue;
225
+ }
226
+ updateSlotMetadata(agent);
227
+ }
228
+ return records;
229
+ }
230
+ function withMaintenance(handler) {
231
+ return async (params) => {
232
+ const zombieRecords = runToolMaintenance();
233
+ const result = await handler(params, zombieRecords);
234
+ if (result && typeof result === "object" && "content" in result) {
235
+ return withZombieReport(result, zombieRecords);
236
+ }
237
+ return result;
238
+ };
239
+ }
57
240
  export function classifyFailureReason(reason, stderr) {
58
241
  const text = `${reason}\n${stderr}`;
59
242
  return /\b429\b|\b5\d{2}\b|quota|usage.?cap|rate.?limit|timeout|connection.?reset|ECONNRESET|ETIMEDOUT|ECONNREFUSED|too many requests|service unavailable|server error|overloaded/i.test(text)
@@ -144,7 +327,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
144
327
  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.";
145
328
  const server = new McpServer({
146
329
  name: "subagent-mcp",
147
- version: "2.10.0",
330
+ version: "2.10.1",
148
331
  description: "Launches always-interactive local Claude and Codex sub-agent sessions. Claude uses the Claude Agent SDK over the local Claude Code executable; Codex uses `codex app-server` over stdio. The server does not call Anthropic or OpenAI HTTP APIs directly.",
149
332
  }, {
150
333
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -314,6 +497,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
314
497
  // Heartbeat refreshes only on parsed visible provider stream items,
315
498
  // not on raw stdout bytes.
316
499
  agentState.lastActivity = at;
500
+ updateSlotMetadata(agentState);
317
501
  agentState.visibleStream = retainLastN(agentState.visibleStream, items.map((it) => ({ ...it, at })), 3);
318
502
  }
319
503
  // Provider completion events mark the current turn finished while the
@@ -365,6 +549,11 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
365
549
  // Always record actual close time (unless already finalized)
366
550
  if (agentState.exitedAt === null)
367
551
  agentState.exitedAt = Date.now();
552
+ if (agentState.status === "zombie_killed") {
553
+ if (agentState.exitCode === null)
554
+ agentState.exitCode = code !== null ? code : -1;
555
+ return;
556
+ }
368
557
  if (agentState.status === "stopped") {
369
558
  // Record real exit code but preserve "stopped" status
370
559
  if (agentState.exitCode === null)
@@ -503,7 +692,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
503
692
  effort: z.enum(["medium", "high", "xhigh", "max", "ultracode"]).optional(),
504
693
  cwd: z.string().optional(),
505
694
  deadlock: z.boolean().optional().describe("MANDATE: ALWAYS set deadlock=true when, and ONLY when, 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory — the 3rd attempt onward. Re-wording the prompt does NOT make it a different task; splitting a failed task does NOT reset attempts for its unchanged parts; re-launching for the same deliverable means the prior attempt COUNTS as failed/unsatisfactory ('partial progress' is not an exemption). NEVER set it on a 1st or 2nd attempt, NEVER for a different task, NEVER speculatively. Auto mode only: cannot be combined with provider/model/effort — from the 3rd attempt deadlock outranks any capability override, so drop those params. Passing false is identical to omitting it."),
506
- }, async (params) => {
695
+ }, withMaintenance(async (params) => {
507
696
  const { task_category, provider, model, effort, deadlock } = params;
508
697
  // D19/D20/S8: server silently upserts the parent-process marker as the TRUE
509
698
  // first line of every sub-agent prompt (idempotent; never duplicates; never
@@ -604,7 +793,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
604
793
  // advance on launch-time failure. Sub-agent task outcome is NEVER a trigger.
605
794
  const cap = readGlobalCap();
606
795
  const reservationId = randomUUID();
607
- const reservation = reserveSlot(reservationId, cap);
796
+ const reservation = reserveSlot(reservationId, cap, slotDir(), NONBLOCKING_CULL_DEPS);
608
797
  if (!reservation.ok) {
609
798
  return errorResult(globalCapMessage(reservation.current, cap, defaultConfigPath()));
610
799
  }
@@ -622,6 +811,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
622
811
  const registeredAgent = agents.get(outcome.agentId);
623
812
  if (registeredAgent) {
624
813
  registeredAgent.slotPath = reservation.slotPath;
814
+ updateSlotMetadata(registeredAgent);
625
815
  // The child can reach a terminal state DURING tryLaunchCandidate's
626
816
  // awaits — before slotPath was set — so its close handler ran
627
817
  // releaseSlot(undefined) (a no-op) and left the slot leaked. Detect
@@ -706,12 +896,12 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
706
896
  .map((s, i) => ` ${i + 1}. ${s.model}@${s.effort} (${s.provider}) [${s.failure_type}]: ${s.reason}`)
707
897
  .join("\n");
708
898
  return errorResult(`Error: all ${skipped.length} candidate launches failed for task_category ${task_category}:\n${lines}\n${SPLIT_HINT}\n${AUTO_HINT}`);
709
- });
899
+ }));
710
900
  // Tool 2: poll_agent
711
901
  server.tool("poll_agent", "Get an agent's current status and output. Status `processing` = ALIVE with visible provider activity in the last 10 minutes; `stalled` = ALIVE but no parsed visible provider stream item for 10 minutes (thinking, or awaiting a temp-file handoff) — NOT dead, so prefer `wait`/re-poll over killing. Always returns `alive` and `idle_seconds`, plus `recent_stream` (the last 3 visible provider stream items, each timestamped) and a `hint` while stalled. Pass `verbose: true` to also return `final_output`, the agent's final assistant turn extracted from its captured stdout.", {
712
902
  agent_id: z.string(),
713
903
  verbose: z.boolean().optional().default(false),
714
- }, async (params) => {
904
+ }, withMaintenance(async (params) => {
715
905
  const agent = agents.get(params.agent_id);
716
906
  if (!agent) {
717
907
  return {
@@ -776,11 +966,11 @@ server.tool("poll_agent", "Get an agent's current status and output. Status `pro
776
966
  },
777
967
  ],
778
968
  };
779
- });
969
+ }));
780
970
  // Tool 3: kill_agent
781
971
  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.", {
782
972
  agent_id: z.string(),
783
- }, async (params) => {
973
+ }, withMaintenance(async (params) => {
784
974
  const agent = agents.get(params.agent_id);
785
975
  if (!agent) {
786
976
  return {
@@ -856,12 +1046,12 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
856
1046
  isError: true,
857
1047
  };
858
1048
  }
859
- });
1049
+ }));
860
1050
  // Tool 4: send_message
861
1051
  server.tool("send_message", "Enqueue a user message for an open interactive agent session. Observe output with poll_agent or wait.", {
862
1052
  agent_id: z.string(),
863
1053
  message: z.string().min(1),
864
- }, async (params) => {
1054
+ }, withMaintenance(async (params) => {
865
1055
  const agent = agents.get(params.agent_id);
866
1056
  if (!agent) {
867
1057
  return {
@@ -896,6 +1086,7 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
896
1086
  agent.waitReported = false;
897
1087
  agent.turnCompleted = false;
898
1088
  agent.lastActivity = now;
1089
+ updateSlotMetadata(agent);
899
1090
  return {
900
1091
  content: [
901
1092
  {
@@ -921,9 +1112,9 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
921
1112
  isError: true,
922
1113
  };
923
1114
  }
924
- });
1115
+ }));
925
1116
  // Tool 5: list_agents
926
- 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.", {}, async () => {
1117
+ 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 () => {
927
1118
  const now = Date.now();
928
1119
  const agentList = Array.from(agents.values()).map((agent) => {
929
1120
  // Reconcile exit synchronously so already-exited processes are reported
@@ -950,11 +1141,11 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
950
1141
  },
951
1142
  ],
952
1143
  };
953
- });
1144
+ }));
954
1145
  // Tool 6: wait
955
- server.tool("wait", "Blocks until one or more sub-agents reach a reportable state (turn-finished, errored, or stopped), returning exit code when known + local-time timestamp; or returns the live-job list after a 15-minute timeout. A `finished` agent can still be alive and accept `send_message` when exit_code is null. A `stalled` agent is still ALIVE and does NOT end the wait. Pass `verbose: true` to add each finished agent's `final_output`.", {
1146
+ 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. A `finished` agent can still be alive and accept `send_message` when exit_code is null. A `stalled` agent is still ALIVE and does NOT end the wait. Pass `verbose: true` to add each finished agent's `final_output`.", {
956
1147
  verbose: z.boolean().optional().default(false),
957
- }, async (params) => {
1148
+ }, withMaintenance(async (params) => {
958
1149
  const { verbose } = params;
959
1150
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
960
1151
  const TIMEOUT_MS = 15 * 60 * 1000;
@@ -994,7 +1185,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
994
1185
  }
995
1186
  // Step 2: nothing alive and nothing unreported (includes stopped-but-not-yet-closed).
996
1187
  // `stalled` is a LIVE state — it keeps the wait pending, it never ends it.
997
- const TERMINAL_SET = new Set(["finished", "errored", "stopped"]);
1188
+ const TERMINAL_SET = new Set(["finished", "errored", "stopped", "zombie_killed"]);
998
1189
  const hasPending = Array.from(agents.values()).some((a) => a.status === "processing" ||
999
1190
  a.status === "stalled" ||
1000
1191
  (TERMINAL_SET.has(a.status) && a.exitedAt === null));
@@ -1032,11 +1223,11 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1032
1223
  return {
1033
1224
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1034
1225
  };
1035
- });
1226
+ }));
1036
1227
  // Tool 7: orchestration-mode
1037
1228
  server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF for THIS session only, omit = query current state. SOLE CHANNEL: the subagent MCP is the ONLY sanctioned channel for launching sub-agents whether this mode is ON or OFF — toggling OFF does not lift that obligation. When OFF, run the per-turn 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 the user whether to remain enabled (ask every qualifying turn; a decline does not latch; reset the count only when you actually ask). The FULL operating model + governance is carried in this server's MCP `instructions` (read once at initialize) — this is the operational summary only; do not act on the mode without that detail. WHAT: a default-ON orchestration mode for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as a delegate-ONLY orchestrator: delegate every step; inline-by-right does not exist; a non-delegable atomic step requires a one-time user-approved exception via the structured-question tool (state which + why). PERSISTENCE: orchestration is DEFAULT ON; a permitted disable applies to THIS session only, resumes ON next new session (or after the 2h backstop), and cannot be re-enabled mid-session. CARRYOVER: if ON was inherited from a PRIOR session (provenance = carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker, never per turn) — you MUST then notify the user it auto-activated and confirm whether to keep it ON. DISABLE: never on your own initiative; you MAY PROPOSE turning it 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).", {
1038
1229
  enabled: z.boolean().optional(),
1039
- }, async (params) => {
1230
+ }, withMaintenance(async (params) => {
1040
1231
  const cwd = process.cwd();
1041
1232
  const key = orchestrationMarker.readCurrentSession(cwd);
1042
1233
  if (params.enabled === true) {
@@ -1084,11 +1275,11 @@ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MOD
1084
1275
  },
1085
1276
  ],
1086
1277
  };
1087
- });
1278
+ }));
1088
1279
  // Tool 8: model-selection-mode
1089
1280
  server.tool("model-selection-mode", "Set or query per-project MODEL SELECTION MODE, which gates launch_agent's `provider`/`model`/`effort` selectors. `mode`: \"smart\" or \"user-approved-overrides\"; omit to query current state. \"smart\" is the DEFAULT and is used whenever the mode is unset — in smart, launch_agent REJECTS any call that supplies provider/model/effort and the server auto-picks the best model for the task_category. \"user-approved-overrides\" opens a 30-MINUTE window during which selectors are HONORED; the window is enforced LAZILY — the mode reverts to smart on the next launch_agent call after the 30 minutes elapse — and re-enabling does NOT extend an already-active window. HONOR-BASED, parallel to orchestration-mode: you MUST NOT set this to \"user-approved-overrides\" without explicit interactive USER authorization obtained via the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex; a plain yes/no exchange if neither exists). This tool CANNOT verify that authorization — never enable it on your own initiative. PERSISTENCE: per-project state keyed by cwd; both the mode and the override-window enable-timestamp persist across MCP server restarts (the remaining window is restored, not reset).", {
1090
1281
  mode: z.enum(["smart", "user-approved-overrides"]).optional(),
1091
- }, async (params) => {
1282
+ }, withMaintenance(async (params) => {
1092
1283
  const cwd = process.cwd();
1093
1284
  if (params.mode)
1094
1285
  modelMode.setMode(cwd, params.mode);
@@ -1106,7 +1297,7 @@ server.tool("model-selection-mode", "Set or query per-project MODEL SELECTION MO
1106
1297
  },
1107
1298
  ],
1108
1299
  };
1109
- });
1300
+ }));
1110
1301
  // Connect the stdio transport only when run as the entry point (the bin), NOT
1111
1302
  // when this module is imported (e.g. test/handler-validation.test.mjs importing
1112
1303
  // the exported validatePresence). Connecting on import would block the test on
@@ -1146,7 +1337,17 @@ if (isMain) {
1146
1337
  }
1147
1338
  if (arg === "update" || arg === "--update") {
1148
1339
  const pkg = readPkg();
1149
- const npmArgs = ["install", "-g", `${pkg.name}@latest`];
1340
+ const scope = pkg.name.startsWith("@") ? pkg.name.split("/")[0] : null;
1341
+ const npmjsRegistryArgs = [
1342
+ "--registry=https://registry.npmjs.org",
1343
+ ...(scope ? [`--${scope}:registry=https://registry.npmjs.org`] : []),
1344
+ ];
1345
+ const npmArgs = [
1346
+ "install",
1347
+ "-g",
1348
+ ...npmjsRegistryArgs,
1349
+ `${pkg.name}@latest`,
1350
+ ];
1150
1351
  console.log(`subagent-mcp ${pkg.version} -> npm ${npmArgs.join(" ")}`);
1151
1352
  // npm on Windows is npm.cmd; spawning a .cmd without a shell fails
1152
1353
  // (EINVAL on modern Node). Resolve the underlying npm-cli.js and run it
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import * as marker from "./marker.js";
6
6
  import * as reminder from "./reminder.js";
7
+ import { cullStaleSlots, slotDir, ZOMBIE_FORCE_GRACE_MS, } from "../concurrency.js";
7
8
  /**
8
9
  * Provider-agnostic core of the UserPromptSubmit / SessionStart hook.
9
10
  *
@@ -210,6 +211,35 @@ export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
210
211
  ? readDirective(env, adapter.carryoverDirectiveFile) + full
211
212
  : full;
212
213
  }
214
+ function hookCullDeps(env = process.env) {
215
+ return {
216
+ forceGraceMs: () => {
217
+ const raw = env.SUBAGENT_ZOMBIE_FORCE_GRACE_MS;
218
+ if (raw === undefined || raw === "")
219
+ return ZOMBIE_FORCE_GRACE_MS;
220
+ const parsed = Number.parseInt(raw, 10);
221
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : ZOMBIE_FORCE_GRACE_MS;
222
+ },
223
+ };
224
+ }
225
+ export function cullHookZombies(deps = hookCullDeps()) {
226
+ try {
227
+ return cullStaleSlots(slotDir(), deps);
228
+ }
229
+ catch {
230
+ return [];
231
+ }
232
+ }
233
+ export function hookZombieReportText(records) {
234
+ const ids = Array.from(new Set(records.map((r) => r.agent_id))).filter(Boolean);
235
+ return ids.length > 0 ? `zombies: ${ids.join(",")}` : "";
236
+ }
237
+ export function appendHookZombieReport(out, records) {
238
+ const report = hookZombieReportText(records);
239
+ if (!report)
240
+ return out;
241
+ return out ? `${out}\n${report}` : report;
242
+ }
213
243
  /**
214
244
  * Core hook logic. Returns the string to inject, or '' to inject nothing.
215
245
  *
@@ -229,8 +259,9 @@ export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
229
259
  */
230
260
  export function runHook(payload, env, adapter) {
231
261
  try {
262
+ const zombieRecords = cullHookZombies();
232
263
  if (adapter.isSubagent(payload, env)) {
233
- return "";
264
+ return appendHookZombieReport("", zombieRecords);
234
265
  }
235
266
  const cwd = payload.cwd || process.cwd();
236
267
  const current = sessionKey(payload);
@@ -239,17 +270,17 @@ export function runHook(payload, env, adapter) {
239
270
  if (!marker.isActive(cwd, current)) {
240
271
  // OFF: no claim machinery — just the per-prompt reminder cadence.
241
272
  const r = reminder.advance(cwd, current);
242
- return cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted);
273
+ return appendHookZombieReport(cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted), zombieRecords);
243
274
  }
244
275
  const m = marker.readMarker(cwd);
245
276
  const kind = classifyClaim(m.owner_session, m.baseline_turn, current);
246
277
  if (kind === "fresh" || kind === "carryover") {
247
278
  const turn = adapter.currentTurn(payload.transcript_path);
248
- return claimAndEmit(cwd, current, turn, m, kind, env, adapter);
279
+ return appendHookZombieReport(claimAndEmit(cwd, current, turn, m, kind, env, adapter), zombieRecords);
249
280
  }
250
281
  // SAME-SESSION: per-prompt reminder cadence, ON variant.
251
282
  const r = reminder.advance(cwd, current);
252
- return cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted);
283
+ return appendHookZombieReport(cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted), zombieRecords);
253
284
  }
254
285
  catch {
255
286
  // Any failure -> inject nothing. Never crash or stall the host turn.
@@ -1,4 +1,5 @@
1
1
  import { serverAlive } from "./liveness.js";
2
+ import { cullHookZombies, hookZombieReportText } from "./hook-core.js";
2
3
  const NATIVE_SUBAGENT_TOOLS = new Set(["Task", "Agent", "Explore"]);
3
4
  function decision(permissionDecision, permissionDecisionReason, additionalContext) {
4
5
  return {
@@ -21,17 +22,21 @@ function decision(permissionDecision, permissionDecisionReason, additionalContex
21
22
  */
22
23
  export function runClaudePreTool(payload, env, now = Date.now()) {
23
24
  try {
25
+ const zombieReport = hookZombieReportText(cullHookZombies());
24
26
  if (env.SUBAGENT_MCP_SUBAGENT === "1")
25
27
  return null;
28
+ const zombieAllowedDecision = zombieReport
29
+ ? decision("allow", "zombies culled; allowing requested tool.", zombieReport)
30
+ : null;
26
31
  if (!serverAlive(now))
27
- return null;
32
+ return zombieAllowedDecision;
28
33
  const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
29
34
  if (!tool)
30
- return null;
35
+ return zombieAllowedDecision;
31
36
  if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
32
- return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.");
37
+ return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.", zombieReport || undefined);
33
38
  }
34
- return null;
39
+ return zombieAllowedDecision;
35
40
  }
36
41
  catch {
37
42
  return null;
@@ -9,6 +9,7 @@
9
9
  // finished - current turn completed, or driver exited 0.
10
10
  // errored - process exited non-zero.
11
11
  // stopped - process was killed.
12
+ // zombie_killed - stale process tree was culled outside the owning server.
12
13
  //
13
14
  // Liveness is driven by a heartbeat: launch time is the initial heartbeat and
14
15
  // every subsequent PARSED visible provider stream item refreshes it (raw
@@ -44,8 +45,7 @@ export function computeStatusTransition(input) {
44
45
  // opts in (poll_agent does; list_agents omits it to stay token-efficient).
45
46
  export function buildLivenessFields(status, exitCode, lastActivity, now, includeHint = true) {
46
47
  const idle_seconds = Math.floor((now - lastActivity) / 1000);
47
- const alive = exitCode === null &&
48
- (status === "processing" || status === "stalled" || status === "finished");
48
+ const alive = exitCode === null && (status === "processing" || status === "stalled");
49
49
  const fields = { alive, idle_seconds };
50
50
  if (status === "stalled" && includeHint) {
51
51
  fields.hint =
@@ -15,7 +15,7 @@ export function formatLocalIso(ms) {
15
15
  const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
16
16
  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${sign}${offH}:${offM} (${zone})`;
17
17
  }
18
- const TERMINAL_STATUSES = new Set(["finished", "errored", "stopped"]);
18
+ const TERMINAL_STATUSES = new Set(["finished", "errored", "stopped", "zombie_killed"]);
19
19
  export function selectUnreported(list) {
20
20
  return list.filter((a) => TERMINAL_STATUSES.has(a.status) && a.exitedAt !== null && !a.waitReported);
21
21
  }