@heretyc/subagent-mcp 2.9.2 → 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,6 +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, 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
22
  import * as orchestrationMarker from "./orchestration/marker.js";
22
23
  import * as modelMode from "./orchestration/model-mode.js";
23
24
  import { startLivenessHeartbeat } from "./orchestration/liveness.js";
@@ -53,6 +54,189 @@ const TASK_CATEGORY_GLOSS = "REQUIRED. Task shape -> routing category (server pi
53
54
  function errorResult(text) {
54
55
  return { content: [{ type: "text", text }], isError: true };
55
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
+ }
56
240
  export function classifyFailureReason(reason, stderr) {
57
241
  const text = `${reason}\n${stderr}`;
58
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)
@@ -143,7 +327,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
143
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.";
144
328
  const server = new McpServer({
145
329
  name: "subagent-mcp",
146
- version: "2.9.2",
330
+ version: "2.10.1",
147
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.",
148
332
  }, {
149
333
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -313,6 +497,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
313
497
  // Heartbeat refreshes only on parsed visible provider stream items,
314
498
  // not on raw stdout bytes.
315
499
  agentState.lastActivity = at;
500
+ updateSlotMetadata(agentState);
316
501
  agentState.visibleStream = retainLastN(agentState.visibleStream, items.map((it) => ({ ...it, at })), 3);
317
502
  }
318
503
  // Provider completion events mark the current turn finished while the
@@ -359,9 +544,16 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
359
544
  }
360
545
  // Always clean up ultracode settings file on close
361
546
  cleanupUcSettings(agentState);
547
+ releaseSlot(agentState.slotPath ?? null);
548
+ agentState.slotPath = null;
362
549
  // Always record actual close time (unless already finalized)
363
550
  if (agentState.exitedAt === null)
364
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
+ }
365
557
  if (agentState.status === "stopped") {
366
558
  // Record real exit code but preserve "stopped" status
367
559
  if (agentState.exitCode === null)
@@ -500,7 +692,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
500
692
  effort: z.enum(["medium", "high", "xhigh", "max", "ultracode"]).optional(),
501
693
  cwd: z.string().optional(),
502
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."),
503
- }, async (params) => {
695
+ }, withMaintenance(async (params) => {
504
696
  const { task_category, provider, model, effort, deadlock } = params;
505
697
  // D19/D20/S8: server silently upserts the parent-process marker as the TRUE
506
698
  // first line of every sub-agent prompt (idempotent; never duplicates; never
@@ -599,69 +791,95 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
599
791
  }
600
792
  // 6. Attempt loop: best→worst. Register on first successful driver start; silently
601
793
  // advance on launch-time failure. Sub-agent task outcome is NEVER a trigger.
794
+ const cap = readGlobalCap();
795
+ const reservationId = randomUUID();
796
+ const reservation = reserveSlot(reservationId, cap, slotDir(), NONBLOCKING_CULL_DEPS);
797
+ if (!reservation.ok) {
798
+ return errorResult(globalCapMessage(reservation.current, cap, defaultConfigPath()));
799
+ }
602
800
  const skipped = [];
603
- for (const candidate of candidates) {
604
- const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
605
- ? { applied: true, originalSelection: rulesetOriginalSelection }
606
- : undefined);
607
- if ("agentId" in outcome) {
608
- if (branch === "performance") {
609
- deadlockWindow.consume();
610
- }
611
- if (skipped.length > 0) {
801
+ let launched = false;
802
+ try {
803
+ for (const candidate of candidates) {
804
+ const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
805
+ ? { applied: true, originalSelection: rulesetOriginalSelection }
806
+ : undefined);
807
+ if ("agentId" in outcome) {
808
+ if (branch === "performance") {
809
+ deadlockWindow.consume();
810
+ }
612
811
  const registeredAgent = agents.get(outcome.agentId);
613
812
  if (registeredAgent) {
614
- registeredAgent.failoverFrom = skipped.map((s) => ({
615
- provider: s.provider,
616
- model: s.model,
617
- effort: s.effort,
618
- failure_type: s.failure_type,
619
- }));
813
+ registeredAgent.slotPath = reservation.slotPath;
814
+ updateSlotMetadata(registeredAgent);
815
+ // The child can reach a terminal state DURING tryLaunchCandidate's
816
+ // awaits — before slotPath was set — so its close handler ran
817
+ // releaseSlot(undefined) (a no-op) and left the slot leaked. Detect
818
+ // that here and release now. slotPath is unique per reservation, so
819
+ // this never frees another agent's slot.
820
+ if (registeredAgent.exitCode !== null) {
821
+ releaseSlot(registeredAgent.slotPath);
822
+ registeredAgent.slotPath = null;
823
+ }
824
+ if (skipped.length > 0) {
825
+ registeredAgent.failoverFrom = skipped.map((s) => ({
826
+ provider: s.provider,
827
+ model: s.model,
828
+ effort: s.effort,
829
+ failure_type: s.failure_type,
830
+ }));
831
+ }
620
832
  }
833
+ launched = true;
834
+ return {
835
+ content: [
836
+ {
837
+ type: "text",
838
+ text: JSON.stringify({
839
+ agent_id: outcome.agentId,
840
+ status: "processing",
841
+ provider: candidate.provider,
842
+ model: candidate.model,
843
+ effort: candidate.effort,
844
+ task_category,
845
+ ...(rulesetApplied
846
+ ? {
847
+ ruleset_applied: true,
848
+ ruleset_original_selection: rulesetOriginalSelection,
849
+ }
850
+ : {}),
851
+ ...(skipped.length > 0
852
+ ? {
853
+ failover_occurred: true,
854
+ failover_from: skipped.map((s) => ({
855
+ provider: s.provider,
856
+ model: s.model,
857
+ effort: s.effort,
858
+ failure_type: s.failure_type,
859
+ })),
860
+ failover_note: buildFailoverNote(skipped, candidate),
861
+ }
862
+ : {}),
863
+ }),
864
+ },
865
+ ],
866
+ };
867
+ }
868
+ skipped.push({
869
+ model: candidate.model,
870
+ effort: candidate.effort,
871
+ provider: candidate.provider,
872
+ reason: outcome.reason,
873
+ failure_type: outcome.failure_type,
874
+ });
875
+ if (!pureAuto) {
876
+ break;
621
877
  }
622
- return {
623
- content: [
624
- {
625
- type: "text",
626
- text: JSON.stringify({
627
- agent_id: outcome.agentId,
628
- status: "processing",
629
- provider: candidate.provider,
630
- model: candidate.model,
631
- effort: candidate.effort,
632
- task_category,
633
- ...(rulesetApplied
634
- ? {
635
- ruleset_applied: true,
636
- ruleset_original_selection: rulesetOriginalSelection,
637
- }
638
- : {}),
639
- ...(skipped.length > 0
640
- ? {
641
- failover_occurred: true,
642
- failover_from: skipped.map((s) => ({
643
- provider: s.provider,
644
- model: s.model,
645
- effort: s.effort,
646
- failure_type: s.failure_type,
647
- })),
648
- failover_note: buildFailoverNote(skipped, candidate),
649
- }
650
- : {}),
651
- }),
652
- },
653
- ],
654
- };
655
878
  }
656
- skipped.push({
657
- model: candidate.model,
658
- effort: candidate.effort,
659
- provider: candidate.provider,
660
- reason: outcome.reason,
661
- failure_type: outcome.failure_type,
662
- });
663
- if (!pureAuto) {
664
- break;
879
+ }
880
+ finally {
881
+ if (!launched) {
882
+ releaseSlot(reservation.slotPath);
665
883
  }
666
884
  }
667
885
  // 7. All candidates failed. Override selector modes are single-attempt hard
@@ -678,12 +896,12 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
678
896
  .map((s, i) => ` ${i + 1}. ${s.model}@${s.effort} (${s.provider}) [${s.failure_type}]: ${s.reason}`)
679
897
  .join("\n");
680
898
  return errorResult(`Error: all ${skipped.length} candidate launches failed for task_category ${task_category}:\n${lines}\n${SPLIT_HINT}\n${AUTO_HINT}`);
681
- });
899
+ }));
682
900
  // Tool 2: poll_agent
683
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.", {
684
902
  agent_id: z.string(),
685
903
  verbose: z.boolean().optional().default(false),
686
- }, async (params) => {
904
+ }, withMaintenance(async (params) => {
687
905
  const agent = agents.get(params.agent_id);
688
906
  if (!agent) {
689
907
  return {
@@ -748,11 +966,11 @@ server.tool("poll_agent", "Get an agent's current status and output. Status `pro
748
966
  },
749
967
  ],
750
968
  };
751
- });
969
+ }));
752
970
  // Tool 3: kill_agent
753
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.", {
754
972
  agent_id: z.string(),
755
- }, async (params) => {
973
+ }, withMaintenance(async (params) => {
756
974
  const agent = agents.get(params.agent_id);
757
975
  if (!agent) {
758
976
  return {
@@ -790,6 +1008,8 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
790
1008
  // POSIX, SIGKILL the process (close handler records the real exit code).
791
1009
  agent.status = "stopped";
792
1010
  agent.driver.kill();
1011
+ releaseSlot(agent.slotPath ?? null);
1012
+ agent.slotPath = null;
793
1013
  if (isWindows && agent.process.pid) {
794
1014
  spawn("taskkill", ["/pid", String(agent.process.pid), "/t", "/f"], {
795
1015
  windowsHide: true,
@@ -826,12 +1046,12 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
826
1046
  isError: true,
827
1047
  };
828
1048
  }
829
- });
1049
+ }));
830
1050
  // Tool 4: send_message
831
1051
  server.tool("send_message", "Enqueue a user message for an open interactive agent session. Observe output with poll_agent or wait.", {
832
1052
  agent_id: z.string(),
833
1053
  message: z.string().min(1),
834
- }, async (params) => {
1054
+ }, withMaintenance(async (params) => {
835
1055
  const agent = agents.get(params.agent_id);
836
1056
  if (!agent) {
837
1057
  return {
@@ -866,6 +1086,7 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
866
1086
  agent.waitReported = false;
867
1087
  agent.turnCompleted = false;
868
1088
  agent.lastActivity = now;
1089
+ updateSlotMetadata(agent);
869
1090
  return {
870
1091
  content: [
871
1092
  {
@@ -891,9 +1112,9 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
891
1112
  isError: true,
892
1113
  };
893
1114
  }
894
- });
1115
+ }));
895
1116
  // Tool 5: list_agents
896
- 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 () => {
897
1118
  const now = Date.now();
898
1119
  const agentList = Array.from(agents.values()).map((agent) => {
899
1120
  // Reconcile exit synchronously so already-exited processes are reported
@@ -920,11 +1141,11 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
920
1141
  },
921
1142
  ],
922
1143
  };
923
- });
1144
+ }));
924
1145
  // Tool 6: wait
925
- 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`.", {
926
1147
  verbose: z.boolean().optional().default(false),
927
- }, async (params) => {
1148
+ }, withMaintenance(async (params) => {
928
1149
  const { verbose } = params;
929
1150
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
930
1151
  const TIMEOUT_MS = 15 * 60 * 1000;
@@ -964,7 +1185,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
964
1185
  }
965
1186
  // Step 2: nothing alive and nothing unreported (includes stopped-but-not-yet-closed).
966
1187
  // `stalled` is a LIVE state — it keeps the wait pending, it never ends it.
967
- const TERMINAL_SET = new Set(["finished", "errored", "stopped"]);
1188
+ const TERMINAL_SET = new Set(["finished", "errored", "stopped", "zombie_killed"]);
968
1189
  const hasPending = Array.from(agents.values()).some((a) => a.status === "processing" ||
969
1190
  a.status === "stalled" ||
970
1191
  (TERMINAL_SET.has(a.status) && a.exitedAt === null));
@@ -1002,11 +1223,11 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1002
1223
  return {
1003
1224
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1004
1225
  };
1005
- });
1226
+ }));
1006
1227
  // Tool 7: orchestration-mode
1007
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).", {
1008
1229
  enabled: z.boolean().optional(),
1009
- }, async (params) => {
1230
+ }, withMaintenance(async (params) => {
1010
1231
  const cwd = process.cwd();
1011
1232
  const key = orchestrationMarker.readCurrentSession(cwd);
1012
1233
  if (params.enabled === true) {
@@ -1054,11 +1275,11 @@ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MOD
1054
1275
  },
1055
1276
  ],
1056
1277
  };
1057
- });
1278
+ }));
1058
1279
  // Tool 8: model-selection-mode
1059
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).", {
1060
1281
  mode: z.enum(["smart", "user-approved-overrides"]).optional(),
1061
- }, async (params) => {
1282
+ }, withMaintenance(async (params) => {
1062
1283
  const cwd = process.cwd();
1063
1284
  if (params.mode)
1064
1285
  modelMode.setMode(cwd, params.mode);
@@ -1076,7 +1297,7 @@ server.tool("model-selection-mode", "Set or query per-project MODEL SELECTION MO
1076
1297
  },
1077
1298
  ],
1078
1299
  };
1079
- });
1300
+ }));
1080
1301
  // Connect the stdio transport only when run as the entry point (the bin), NOT
1081
1302
  // when this module is imported (e.g. test/handler-validation.test.mjs importing
1082
1303
  // the exported validatePresence). Connecting on import would block the test on
@@ -1116,7 +1337,17 @@ if (isMain) {
1116
1337
  }
1117
1338
  if (arg === "update" || arg === "--update") {
1118
1339
  const pkg = readPkg();
1119
- 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
+ ];
1120
1351
  console.log(`subagent-mcp ${pkg.version} -> npm ${npmArgs.join(" ")}`);
1121
1352
  // npm on Windows is npm.cmd; spawning a .cmd without a shell fails
1122
1353
  // (EINVAL on modern Node). Resolve the underlying npm-cli.js and run it
@@ -1154,7 +1385,9 @@ if (isMain) {
1154
1385
  }
1155
1386
  const installRoot = join(npmRoot.stdout.toString().trim(), ...pkg.name.split("/"));
1156
1387
  const rulesetPath = join(installRoot, "dist", "advanced-ruleset.py");
1388
+ const cfgPath = join(installRoot, "dist", CONFIG_FILENAME);
1157
1389
  let previousRuleset = null;
1390
+ let previousCfg = null;
1158
1391
  if (existsSync(rulesetPath)) {
1159
1392
  previousRuleset = readFileSync(rulesetPath);
1160
1393
  const backupPath = join(tmpdir(), `advanced-ruleset.py.bak-update-${Date.now()}`);
@@ -1167,6 +1400,18 @@ if (isMain) {
1167
1400
  process.exit(1);
1168
1401
  }
1169
1402
  }
1403
+ if (existsSync(cfgPath)) {
1404
+ previousCfg = readFileSync(cfgPath);
1405
+ const backupPath = join(tmpdir(), `${CONFIG_FILENAME}.bak-update-${Date.now()}`);
1406
+ try {
1407
+ writeFileSync(backupPath, previousCfg);
1408
+ console.log(`backed up user ${CONFIG_FILENAME} to ${backupPath}`);
1409
+ }
1410
+ catch (e) {
1411
+ console.error(`update refused before install: failed to back up ${CONFIG_FILENAME}: ${e instanceof Error ? e.message : String(e)}`);
1412
+ process.exit(1);
1413
+ }
1414
+ }
1170
1415
  const r = spawnNpm(npmArgs, "inherit");
1171
1416
  if (r.error) {
1172
1417
  console.error(`update failed to start npm: ${r.error.message}`);
@@ -1189,6 +1434,21 @@ if (isMain) {
1189
1434
  process.exit(1);
1190
1435
  }
1191
1436
  }
1437
+ if (previousCfg !== null) {
1438
+ try {
1439
+ const freshCfg = existsSync(cfgPath)
1440
+ ? readFileSync(cfgPath)
1441
+ : null;
1442
+ if (freshCfg === null || !previousCfg.equals(freshCfg)) {
1443
+ writeFileSync(cfgPath, previousCfg);
1444
+ console.log(`restored user ${CONFIG_FILENAME} (package update never overwrites user edits)`);
1445
+ }
1446
+ }
1447
+ catch (e) {
1448
+ console.error(`update failed to restore ${CONFIG_FILENAME}: ${e instanceof Error ? e.message : String(e)}`);
1449
+ process.exit(1);
1450
+ }
1451
+ }
1192
1452
  console.log("Update complete. Restart your CLI sessions so the MCP server picks up the new build.");
1193
1453
  }
1194
1454
  process.exit(code);
@@ -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.