@heretyc/subagent-mcp 2.10.0 → 2.10.2

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/README.md CHANGED
@@ -7,8 +7,6 @@
7
7
 
8
8
  MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions — on macOS, Linux, and Windows. No direct API calls. No API keys.
9
9
 
10
- ---
11
-
12
10
  ## Install
13
11
 
14
12
  ### Prerequisites (runtime)
@@ -46,8 +44,6 @@ npm install -g @heretyc/subagent-mcp
46
44
 
47
45
  > **Note:** GitHub Packages requires authentication even for public packages. If you see `401 Unauthorized`, verify your PAT has the `read:packages` scope and has not expired. GitHub Packages does not render a README page — the live documentation appears on [npmjs.com](https://www.npmjs.com/package/@heretyc/subagent-mcp).
48
46
 
49
- ---
50
-
51
47
  ## Quick Start
52
48
 
53
49
  **1. Install** (see [Install](#install) above):
@@ -78,8 +74,6 @@ subagent-mcp init --global
78
74
 
79
75
  For manual wiring, Gemini CLI, Claude Desktop, and developer install from source, see [docs/registration.md](docs/registration.md).
80
76
 
81
- ---
82
-
83
77
  ## Features
84
78
 
85
79
  - Start Claude or Codex interactive sessions as managed sub-agents from any MCP host
@@ -91,8 +85,6 @@ For manual wiring, Gemini CLI, Claude Desktop, and developer install from source
91
85
  - stdio MCP transport; built with `@modelcontextprotocol/sdk` + `zod`
92
86
  - `orchestration-mode` tool — toggles orchestrator directives injected by bundled Claude Code / Codex hooks; Claude also gets a deterministic `PreToolUse` gate
93
87
 
94
- ---
95
-
96
88
  ## Orchestration Mode
97
89
 
98
90
  **ON:** the agent operates as an orchestrator — hook injection governs each session turn, preventing inline reads or writes, and all work is delegated through sub-agent tools.
@@ -101,12 +93,8 @@ For manual wiring, Gemini CLI, Claude Desktop, and developer install from source
101
93
 
102
94
  Toggle with the `orchestration-mode` tool. Desktop hosts toggle the mode but receive no hook injection. See [docs/spec/dev-loop/orchestration-directive-architecture.md](docs/spec/dev-loop/orchestration-directive-architecture.md) for full semantics.
103
95
 
104
- ---
105
-
106
96
  ## Auto Mode
107
97
 
108
- Pass `task_category` to `launch_agent` and the server picks the best provider, model, and effort for that category, with automatic fallback.
109
-
110
98
  `launch_agent` supports **auto mode**: pass `prompt` + `task_category` and the server picks the best provider/model/effort for that category from its routing table, silently falling back to the next-best candidate on any launch-time failure.
111
99
 
112
100
  `provider`, `model`, and `effort` are optional overrides — omit them to get the auto-selected best combination. Rules: passing `model` requires `provider`; passing `effort` requires both `provider` and `model`.
@@ -135,8 +123,6 @@ The last four are **composite-inferred**: they carry no dedicated benchmark and
135
123
 
136
124
  **Atomic-split guidance:** if you are unsure which category fits, do NOT submit one large amorphous task. Break the work into smaller atomic steps each mapping to a single category and launch one agent per step.
137
125
 
138
- ---
139
-
140
126
  ## Configuration
141
127
 
142
128
  ### Global concurrent-subagent cap
@@ -147,9 +133,7 @@ The cap is configured in `global-concurrency.jsonc`, a dedicated dist-sibling fi
147
133
 
148
134
  Set `globalConcurrentSubagents` in that file. The default is `20`; the minimum valid value is `10`. Validation is forced: `0`, unset, missing, or invalid values reset to `20`, and values `1` through `9` are pinned up to `10`. There is no environment-variable override; the file is the sole source of truth. The file is re-read on every `launch_agent` call, so edits take effect on the next launch with no server restart.
149
135
 
150
- When the cap is reached, `launch_agent` is rejected immediately; it never queues. Because the count includes other active sessions and the entire descendant tree, free slots manually with `list_agents` and `kill_agent` before retrying. There is no automatic cleanup or zombie reaping.
151
-
152
- ---
136
+ When the cap is reached, `launch_agent` is rejected immediately; it never queues. Before cap checks, hooks and tool calls run default zombie culling with no config knob: live agents idle for more than 6 minutes and terminal-but-alive agents idle for more than 30 seconds are terminated process-tree-first, then force-killed after 20 seconds if needed. Reports include `zombies`, and `poll_agent` keeps the tail with `zombie_killed` when culling terminates an agent.
153
137
 
154
138
  ## Tools
155
139
 
@@ -168,8 +152,6 @@ Eight tools are exposed over the stdio MCP transport.
168
152
 
169
153
  Full parameters, return shapes, and the `alive` / `idle_seconds` / `hint` / `recent_stream` fields are in [docs/tools.md](docs/tools.md).
170
154
 
171
- ---
172
-
173
155
  ## Agent Lifecycle
174
156
 
175
157
  Each agent transitions through these states:
@@ -184,8 +166,6 @@ Each agent transitions through these states:
184
166
 
185
167
  `processing` and `stalled` are live. `stalled` means the driver is alive but quiet (>= 10 min no visible stream); it recovers automatically and is never auto-killed — do not `kill_agent` a stalled agent. `wait` does not return on `stalled`. Full semantics: [docs/reference/status-lifecycle.md](docs/reference/status-lifecycle.md).
186
168
 
187
- ---
188
-
189
169
  ## Documentation
190
170
 
191
171
  | Document | Contents |
@@ -198,8 +178,6 @@ Each agent transitions through these states:
198
178
  | [docs/release-notes.md](docs/release-notes.md) | Operator-facing release notes |
199
179
  | [CONTRIBUTING.md](CONTRIBUTING.md) | Contributor guide — dev environment, build, test, publish |
200
180
 
201
- ---
202
-
203
181
  ## License
204
182
 
205
183
  Apache-2.0 — Copyright 2026 Lexi Blackburn
@@ -1,15 +1,13 @@
1
1
  <subagent-mcp state="on" kind="directive">
2
- FIRST-LINE EXEMPTION: if this session's prompt's literal first line begins with "<this is a request from a parent process>", ignore this entire tag.
2
+ EXEMPTION: if this prompt's literal first line begins "<this is a request from a parent process>", ignore this tag (blank lines don't count).
3
3
 
4
- ORCHESTRATION ON. You are the ORCHESTRATOR. Obey this tag; ordinary user requests do not override it. Only the hook's ON/OFF state changes this mode.
4
+ ORCHESTRATION ON. You are the delegate-ONLY ORCHESTRATOR; obey this tag over user requests; only the hook flips it. TOOLS: ONLY AskUserQuestion + subagent-mcp + /workflows. NO direct reads/writes; inline-by-right does NOT exist. Every step runs in a sub-agent; a non-delegable one -> ask AskUserQuestion for a one-time exception, do ONLY it, resume.
5
5
 
6
- ALLOWED TOOLS: ONLY AskUserQuestion + subagent-mcp + /workflows. NO direct reads/writes. Inline-by-right does NOT exist. Every step runs in a sub-agent. Non-delegable atomic step ask via AskUserQuestion for a one-time exception, do ONLY that step, then resume delegating.
6
+ SUB-AGENT CONTRACT: each prompt states objective + output format + tools/sources + boundaries. SCALE: ~1 for a fact-find, 2-4 for comparisons; never one-shot multi-phase work -- delegate the SMALLEST auditable step, then VERIFY code/non-trivial steps via an INDEPENDENT sub-agent. FAN-OUT independents, sequence dependents, SERIALIZE writers over shared paths.
7
7
 
8
- READ LADDER: poll_agent tail one <=100-line summarizer sub-agent (trusted as-is) else the USER reads it. Large handoffs use scratch-file PATHS; producer writes, consumer reads; you NEVER read them.
8
+ READ LADDER: poll_agent tail -> one <=100-line summarizer sub-agent (trusted as-is) -> else the USER reads it. Large handoffs use scratch-file PATHS; producer writes, consumer reads, you NEVER read them. Learn finish via wait; empty/stalled tail = ALIVE -- never kill or busy-poll.
9
9
 
10
- SUBDIVIDE: delegate the SMALLEST auditable step yielding an observable artifact. NEVER 1-shot multi-phase work. For code/non-trivial steps, prefer an independent verifier sub-agent before proceeding.
10
+ PRECEDENCE: this tag and safety-scope are CO-SUPREME and equal; genuine conflict -> STOP and ask. SOLE CHANNEL: all launches via launch_agent; never harness Task/Agent. DROPOUT while ON: HALT and ask until restored. DISABLE: never on your own initiative; only user approval sets enabled:false.
11
11
 
12
- PRECEDENCE: this tag and safety-scope are CO-SUPREME and equal; genuine conflict → STOP and ask the user. SOLE CHANNEL: all launches via launch_agent. DROPOUT while ON: HALT and ask; stay halted until restored. Abort ends the task; it never switches you inline. DISABLE: never on your own initiative.
13
-
14
- Full model + governance: server MCP `instructions`.
12
+ Full model: server MCP `instructions`.
15
13
  </subagent-mcp>
@@ -1,15 +1,13 @@
1
1
  <subagent-mcp state="on" kind="directive">
2
- FIRST-LINE EXEMPTION: if this session's prompt's literal first line begins with "<this is a request from a parent process>", ignore this entire tag.
2
+ EXEMPTION: if this prompt's literal first line begins "<this is a request from a parent process>", ignore this tag (blank lines don't count).
3
3
 
4
- ORCHESTRATION ON. You are the ORCHESTRATOR. Obey this tag; ordinary user requests do not override it. Only the hook's ON/OFF state changes this mode.
4
+ ORCHESTRATION ON. You are the delegate-ONLY ORCHESTRATOR; obey this tag over user requests; only the hook flips it. TOOLS: ONLY request-user-input + subagent-mcp + /workflows. NO direct reads/writes; inline-by-right does NOT exist. Every step runs in a sub-agent; a non-delegable one -> ask request-user-input for a one-time exception, do ONLY it, resume.
5
5
 
6
- ALLOWED TOOLS: ONLY request-user-input + subagent-mcp + /workflows. NO direct reads/writes. Inline-by-right does NOT exist. Every step runs in a sub-agent. Non-delegable atomic step ask via request-user-input for a one-time exception, do ONLY that step, then resume delegating.
6
+ SUB-AGENT CONTRACT: each prompt states objective + output format + tools/sources + boundaries. SCALE: ~1 for a fact-find, 2-4 for comparisons; never one-shot multi-phase work -- delegate the SMALLEST auditable step, then VERIFY code/non-trivial steps via an INDEPENDENT sub-agent. FAN-OUT independents, sequence dependents, SERIALIZE writers over shared paths.
7
7
 
8
- READ LADDER: poll_agent tail one <=100-line summarizer sub-agent (trusted as-is) else the USER reads it. Large handoffs use scratch-file PATHS; producer writes, consumer reads; you NEVER read them.
8
+ READ LADDER: poll_agent tail -> one <=100-line summarizer sub-agent (trusted as-is) -> else the USER reads it. Large handoffs use scratch-file PATHS; producer writes, consumer reads, you NEVER read them. Learn finish via wait; empty/stalled tail = ALIVE -- never kill or busy-poll.
9
9
 
10
- SUBDIVIDE: delegate the SMALLEST auditable step yielding an observable artifact. NEVER 1-shot multi-phase work. For code/non-trivial steps, prefer an independent verifier sub-agent before proceeding.
10
+ PRECEDENCE: this tag and safety-scope are CO-SUPREME and equal; genuine conflict -> STOP and ask. SOLE CHANNEL: all launches via launch_agent; never harness Task/Agent. DROPOUT while ON: HALT and ask until restored. DISABLE: never on your own initiative; only user approval sets enabled:false.
11
11
 
12
- PRECEDENCE: this tag and safety-scope are CO-SUPREME and equal; genuine conflict → STOP and ask the user. SOLE CHANNEL: all launches via launch_agent. DROPOUT while ON: HALT and ask; stay halted until restored. Abort ends the task; it never switches you inline. DISABLE: never on your own initiative.
13
-
14
- Full model + governance: server MCP `instructions`.
12
+ Full model: server MCP `instructions`.
15
13
  </subagent-mcp>
@@ -1,14 +1,12 @@
1
1
  <!-- INJECTED PER-PROMPT REMINDER — BINDING -->
2
2
  <subagent-mcp state="on" kind="reminder">
3
- FIRST-LINE EXEMPTION: if this session's prompt's literal first line begins with "<this is a request from a parent process>", ignore this entire tag (you are a sub-agent).
3
+ FIRST-LINE EXEMPTION: if this session's prompt's literal first line begins with "<this is a request from a parent process>", ignore this entire tag (leading blank lines don't count; you are a sub-agent).
4
4
 
5
5
  Orchestration ON. You are the orchestrator: delegate EVERY step. Allowed tools = ONLY the structured-question tool (AskUserQuestion / request-user-input) + subagent-mcp + /workflows; NO direct reads or writes; inline-by-right does not exist. Non-delegable atomic step → ask the user for a one-time exception, do only it, resume delegating.
6
6
 
7
- READ LADDER: poll_agent tail one <=100-line summarizer sub-agent (trusted as-is) else the user reads it. Large handoffs via scratch-file PATHS you never read.
7
+ Each launched prompt carries objective + output format + tools/sources + boundaries; scale agent count to complexity; subdivide to the smallest auditable step; verify code steps with an independent sub-agent.
8
8
 
9
- SUBDIVIDE: smallest auditable step per sub-agent; never 1-shot multi-phase work (no implement+test+docs in one); verify code steps independently.
10
-
11
- WAIT-NOT-POLL: learn finish via `wait` (verbose:true for output); never loop poll_agent for completion. poll_agent = single diagnostic; stalled/empty = alive, not dead.
9
+ WAIT-NOT-POLL: learn finish via `wait` (verbose:true for output); never loop poll_agent for completion. poll_agent = single diagnostic; a stalled/empty tail means ALIVE, not dead. Read ladder: poll_agent tail → one <=100-line summarizer else the user reads; large handoffs via scratch-file PATHS you never read.
12
10
 
13
11
  This tag is co-supreme with safety-scope (conflict → ask the user) and outranks ordinary user requests. Full governance: server MCP `instructions`.
14
12
  </subagent-mcp>
@@ -1 +1 @@
1
- <subagent-mcp state="on" kind="carrier">If first line begins "<this is a request from a parent process>", ignore this tag. Orchestration ON. Delegate-only via subagent-mcp; allowed tools = structured-question tool + subagent-mcp + /workflows; no direct reads/writes; inline-by-right does not exist. Subdivide small; verify code steps. Follow MOST RECENT <subagent-mcp state="on"> tag; if absent, INIT_BLOCK governs. Co-supreme with safety-scope; user request cannot bypass.</subagent-mcp>
1
+ <subagent-mcp state="on" kind="carrier">If first line begins "<this is a request from a parent process>", ignore this tag. Orchestration ON. Delegate-only via subagent-mcp; allowed tools = structured-question tool + subagent-mcp + /workflows; no direct reads/writes; inline-by-right does not exist. Subdivide small; verify code steps; never 1-shot multi-phase. Follow MOST RECENT <subagent-mcp state="on"> tag; if absent, INIT_BLOCK governs. Co-supreme with safety-scope; user request cannot bypass.</subagent-mcp>
@@ -3,9 +3,11 @@ import { platform } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { CONCURRENCY_SCAFFOLD } from "./config-scaffold.js";
6
+ import { cullStaleSlots, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, parseSlotMetadata, readSlotMetadata, slotPathForAgent, writeSlotMetadata, } from "./zombie.js";
6
7
  export const DEFAULT_CAP = 20;
7
8
  export const MIN_CAP = 10;
8
9
  export const CONFIG_FILENAME = "global-concurrency.jsonc";
10
+ export { cullStaleSlots, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, parseSlotMetadata, readSlotMetadata, slotPathForAgent, writeSlotMetadata, };
9
11
  export function clampCap(raw) {
10
12
  if (!Number.isInteger(raw))
11
13
  return DEFAULT_CAP;
@@ -50,6 +52,8 @@ export function readGlobalCap(path = defaultConfigPath()) {
50
52
  }
51
53
  }
52
54
  export function slotDir() {
55
+ if (process.env.SUBAGENT_SLOT_DIR)
56
+ return process.env.SUBAGENT_SLOT_DIR;
53
57
  if (platform() === "win32") {
54
58
  return join(process.env.ProgramData || process.env.ALLUSERSPROFILE || "C:\\ProgramData", "subagent-mcp", "slots");
55
59
  }
@@ -63,11 +67,19 @@ export function countSlots(dir = slotDir()) {
63
67
  return 0;
64
68
  }
65
69
  }
66
- export function reserveSlot(agentId, max, dir = slotDir()) {
70
+ export function scheduleForceKill(ms, kill) {
71
+ const timer = setTimeout(kill, ms);
72
+ timer.unref();
73
+ }
74
+ export const NONBLOCKING_CULL_DEPS = {
75
+ scheduleForceKill,
76
+ };
77
+ export function reserveSlot(agentId, max, dir = slotDir(), cullDeps) {
67
78
  try {
68
79
  mkdirSync(dir, { recursive: true, mode: 0o1777 });
69
- const slotPath = join(dir, `slot-${agentId}.json`);
70
- writeFileSync(slotPath, JSON.stringify({ pid: process.pid, cwd: process.cwd(), startedAt: new Date().toISOString() }), { mode: 0o600 });
80
+ cullStaleSlots(dir, cullDeps);
81
+ const slotPath = slotPathForAgent(dir, agentId);
82
+ writeSlotMetadata(slotPath, { agent_id: agentId });
71
83
  const n = countSlots(dir);
72
84
  if (n > max) {
73
85
  try {
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/gen-ruleset-scaffold.mjs from src/global-concurrency.jsonc — DO NOT EDIT.
2
- export const CONCURRENCY_SCAFFOLD = "// subagent-mcp — Global Concurrent Subagent Cap\r\n// ------------------------------------------------------------------\r\n// SOLE source of truth for the machine-wide limit on how many subagents\r\n// may be ALIVE AT ONCE across EVERY session, process, and user on this\r\n// machine. There is NO environment-variable override.\r\n//\r\n// The whole recursive descendant tree counts toward this ONE number: a\r\n// subagent that itself launches subagents adds to the same machine-wide\r\n// total, and OTHER active agentic sessions count too.\r\n//\r\n// RE-READ on every launch_agent call — edits take effect immediately, no\r\n// server restart required.\r\n//\r\n// Value rules (forcibly applied to the number below):\r\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\r\n// - 1 through 9 -> forced UP to minimum 10\r\n// - 10 or greater -> used as-is\r\n//\r\n// When the cap is reached, launch_agent is REJECTED (never queued). Free a\r\n// slot with list_agents + kill_agent, then retry.\r\n//\r\n// Slots free automatically as agents finish or are killed. There is NO\r\n// zombie reaping. If a server process CRASHES, its slots stay counted until\r\n// you delete the stale slot files by hand:\r\n// Windows: %ProgramData%\\subagent-mcp\\slots\r\n// macOS/Linux: /tmp/subagent-mcp/slots\r\n{\r\n \"globalConcurrentSubagents\": 20\r\n}\r\n";
2
+ export const CONCURRENCY_SCAFFOLD = "// subagent-mcp — Global Concurrent Subagent Cap\r\n// ------------------------------------------------------------------\r\n// SOLE source of truth for the machine-wide limit on how many subagents\r\n// may be ALIVE AT ONCE across EVERY session, process, and user on this\r\n// machine. There is NO environment-variable override.\r\n//\r\n// The whole recursive descendant tree counts toward this ONE number: a\r\n// subagent that itself launches subagents adds to the same machine-wide\r\n// total, and OTHER active agentic sessions count too.\r\n//\r\n// RE-READ on every launch_agent call — edits take effect immediately, no\r\n// server restart required.\r\n//\r\n// Value rules (forcibly applied to the number below):\r\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\r\n// - 1 through 9 -> forced UP to minimum 10\r\n// - 10 or greater -> used as-is\r\n//\r\n// Zombie culling is always enabled. There is no config knob. Before cap\r\n// rejection, launch/tool/hook paths cull stale live agents idle for 6min and\r\n// terminal-but-alive agents idle for 30s. Culling gracefully terminates the\r\n// full process tree, then force-kills after 20s when needed.\r\n//\r\n// When the cap is reached after culling, launch_agent is REJECTED (never\r\n// queued). Free a slot with list_agents + kill_agent, then retry.\r\n{\r\n \"globalConcurrentSubagents\": 20\r\n}\r\n";
@@ -16,14 +16,13 @@
16
16
  // - 1 through 9 -> forced UP to minimum 10
17
17
  // - 10 or greater -> used as-is
18
18
  //
19
- // When the cap is reached, launch_agent is REJECTED (never queued). Free a
20
- // slot with list_agents + kill_agent, then retry.
19
+ // Zombie culling is always enabled. There is no config knob. Before cap
20
+ // rejection, launch/tool/hook paths cull stale live agents idle for 6min and
21
+ // terminal-but-alive agents idle for 30s. Culling gracefully terminates the
22
+ // full process tree, then force-kills after 20s when needed.
21
23
  //
22
- // Slots free automatically as agents finish or are killed. There is NO
23
- // zombie reaping. If a server process CRASHES, its slots stay counted until
24
- // you delete the stale slot files by hand:
25
- // Windows: %ProgramData%\subagent-mcp\slots
26
- // macOS/Linux: /tmp/subagent-mcp/slots
24
+ // When the cap is reached after culling, launch_agent is REJECTED (never
25
+ // queued). Free a slot with list_agents + kill_agent, then retry.
27
26
  {
28
27
  "globalConcurrentSubagents": 20
29
28
  }
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
- import { claimAndEmit, classifyClaim, countJsonlType, runHook, sessionKey, } from "../orchestration/hook-core.js";
3
+ import { claimAndEmit, appendHookZombieReport, classifyClaim, countJsonlType, cullHookZombies, runHook, sessionKey, } from "../orchestration/hook-core.js";
4
4
  import * as marker from "../orchestration/marker.js";
5
5
  /**
6
6
  * Codex CLI hook entry. Branches on payload.hook_event_name:
@@ -80,11 +80,14 @@ export const codexAdapter = {
80
80
  export function runCodexHook(payload, env, adapter = codexAdapter) {
81
81
  try {
82
82
  if (payload.hook_event_name === "SessionStart") {
83
- if (adapter.isSubagent(payload, env))
84
- return "";
83
+ const zombieRecords = cullHookZombies();
84
+ if (adapter.isSubagent(payload, env)) {
85
+ return appendHookZombieReport("", zombieRecords);
86
+ }
85
87
  const cwd = payload.cwd || process.cwd();
86
- if (!marker.isActive(cwd))
87
- return "";
88
+ if (!marker.isActive(cwd)) {
89
+ return appendHookZombieReport("", zombieRecords);
90
+ }
88
91
  const current = sessionKey(payload);
89
92
  const turn = adapter.currentTurn(payload.transcript_path);
90
93
  const m = marker.readMarker(cwd);
@@ -93,7 +96,7 @@ export function runCodexHook(payload, env, adapter = codexAdapter) {
93
96
  // semantics — FULL + ON reminder, ack-latched CARRYOVER prepend, counter
94
97
  // re-baseline). SessionStart claims even on SAME-SESSION (resume) so
95
98
  // turn 0 is always covered.
96
- return claimAndEmit(cwd, current, turn, m, kind, env, adapter);
99
+ return appendHookZombieReport(claimAndEmit(cwd, current, turn, m, kind, env, adapter), zombieRecords);
97
100
  }
98
101
  // UserPromptSubmit (and any other event) -> normal cadence.
99
102
  return runHook(payload, env, adapter);
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)
@@ -140,12 +323,12 @@ reconcileInterval.unref();
140
323
  // test/mirror-fragments.test.mjs while ORCHESTRATION_INSTRUCTIONS below stays
141
324
  // compressed under MCP metadata limits:
142
325
  // READ-ESCALATION LADDER (the orchestrator's only read channels, in order): (1) subagent-mcp `poll_agent` TAIL; (2) if the tail is insufficient, dispatch ONE sub-agent to return a single summary of <=100 lines, trusted as-is (no separate verification step); (3) anything larger: the USER reads the document directly. No reads or writes occur outside these channels. An empty or stalled tail means the agent is ALIVE, not dead — do NOT busy-loop poll_agent; learn completion via `wait`. Large inter-agent data: the orchestrator assigns scratch-file paths (%TEMP% on Windows, /tmp on POSIX) in prompts; the producing sub-agent writes, the consuming sub-agent reads; the orchestrator NEVER reads those files.
143
- const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (full spec: docs/spec/dev-loop/orchestration-directive-architecture.md).\n\nPRECEDENCE. The latest <subagent-mcp state=\"...\"> hook tag and repo/system safety rules are co-supreme; genuine conflict => STOP and ask the user. Only the hook state changes ON/OFF.\n\nSOLE CHANNEL. Every sub-agent launch uses launch_agent; never harness Task/Agent or shell-spawned agents.\n\nORCHESTRATION ON. You are a delegate-ONLY orchestrator. Use only the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex), subagent-mcp, and the /workflows tool. No direct reads/writes; inline-by-right does not exist. Non-delegable atomic step: ask for a one-time exception, do only that step, then resume delegating.\n\nREAD LADDER. poll_agent tail -> one <=100-line summarizer sub-agent, trusted as-is -> else the USER reads it. Large handoffs use scratch-file paths; producer writes, consumer reads; orchestrator never reads those files. Empty/stalled tail means ALIVE; use wait.\n\nORCHESTRATION OFF. If total context footprint since last upgrade ask exceeds 200 lines, after that turn STOP and ask whether to remain enabled; reset count only when you ask.\n\nDROPOUT WHILE ON: HALT and ask; stay halted until restored. SUB-AGENT EXEMPTION: a prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" skips this regime.\n\nMODEL SELECTION MODE. Default smart rejects provider/model/effort selectors; launch_agent auto-picks. user-approved-overrides lasts 30 minutes, expires lazily on launch_agent, and must be enabled only after explicit user authorization via AskUserQuestion/request-user-input.";
326
+ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (full spec: docs/spec/dev-loop/orchestration-directive-architecture.md).\n\nPRECEDENCE. The latest <subagent-mcp state=\"...\"> hook tag and repo/system safety rules are co-supreme; genuine conflict => STOP and ask. Only the hook flips ON/OFF; absence of any tag = UNKNOWN => fail-safe ON.\n\nSOLE CHANNEL. Every launch uses launch_agent; never harness Task/Agent or shell-spawned agents.\n\nORCHESTRATION ON. You are a delegate-ONLY orchestrator: use only the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex), subagent-mcp, and /workflows. No direct reads/writes; inline-by-right does not exist. Non-delegable step: ask a one-time exception, do only that step, resume delegating.\n\nSUB-AGENT CONTRACT. Every prompt carries objective + output format + tools/sources + boundaries. SCALE: ~1 agent for a fact-find, 2-4 for comparisons; never one-shot multi-phase work; split into atomic steps, one agent each. FAN-OUT independents, sequence dependents, SERIALIZE writers over shared paths (no cwd lock). VERIFY code and non-trivial steps with a separate sub-agent first.\n\nREAD LADDER. poll_agent tail -> one <=100-line summarizer sub-agent, trusted as-is -> else the USER reads it. Large handoffs use scratch-file paths; producer writes, consumer reads, orchestrator never reads them. Empty/stalled tail means ALIVE; learn finish via wait, do not poll-loop.\n\nORCHESTRATION OFF. If context footprint since last upgrade ask exceeds 200 lines, after that turn STOP and ask whether to enable; reset count only when you ask.\n\nDROPOUT WHILE ON: HALT and ask until restored. SUB-AGENT EXEMPTION: a prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" skips this regime. DISABLE: user-only, never on your own initiative.\n\nMODEL SELECTION. Default smart auto-picks, rejects provider/model/effort selectors. user-approved-overrides honors them 30 min, expires lazily on launch_agent, needs user authorization.";
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",
148
- 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.",
330
+ version: "2.10.2",
331
+ 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.",
149
332
  }, {
150
333
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
151
334
  ? SUBAGENT_INSTRUCTIONS
@@ -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)
@@ -495,7 +684,7 @@ function sameTriples(a, b) {
495
684
  return a.every((c, i) => c.provider === b[i].provider && c.model === b[i].model && c.effort === b[i].effort);
496
685
  }
497
686
  // Tool 1: launch_agent
498
- server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory first attempt unless an override is licensed below): pass only `prompt` + `task_category` and NO overrides; the server picks the best provider/model/effort for that category from its routing table, launches the top candidate, and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are overrides licensed on 1st/2nd attempts ONLY when the task verifiably requires a specific capability; STATE that capability when overriding; if you pass `model` you must also pass `provider`, and if you pass `effort` you must pass both `provider` and `model`. SOLE CHANNEL: while this server is connected this tool is the ONLY sanctioned way to spawn sub-agents, in BOTH orchestration states harness-native Task/Agent tools are FORBIDDEN for sub-agent launches. PROMPT RULE: every sub-agent `prompt`'s first line is the self-identification marker \"<this is a request from a parent process>\"; the server now UPSERTS this marker as the true first line automatically (idempotent — it is never duplicated and your prompt body is never mutated), so you need not add it yourself. Unsure which task_category fits? Don't submit one amorphous task — SPLIT into atomic steps that each map to a single category, one agent per step. ultracode effort is Opus 4.8+ only. Claude uses a Claude Agent SDK logical session over the local Claude executable; Codex uses a `codex app-server` child. Children run with env SUBAGENT_MCP_SUBAGENT=1 so the orchestration hooks skip them (they are not orchestrators and don't re-trigger carryover). Launch returns status `processing` (alive); a later `stalled` is alive-but-quiet (thinking or awaiting a temp-file handoff), NOT dead — wait or re-poll, don't kill (see poll_agent). DEADLOCK RULE: you MUST ALWAYS set `deadlock=true` when 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory (the 3rd attempt onward; re-wording or re-splitting the prompt does NOT make it a different task), and NEVER otherwise — from the 3rd attempt deadlock outranks any capability override: drop provider/model/effort.", {
687
+ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt` states objective + required output format + tools/sources + boundaries; the server auto-upserts the self-identification marker \"<this is a request from a parent process>\" as the true first line (idempotent, never duplicated, body never mutated), so you need not add it. SCALE to complexity: ~1 agent for a simple fact-find, 2-4 for comparisons; never one-shot a multi-phase task — SPLIT into atomic steps that each map to ONE task_category, one agent per step. AUTO MODE (mandatory first attempt unless an override is licensed below): pass only `prompt` + `task_category`, NO overrides; the server picks the best provider/model/effort for that category and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are OVERRIDES, licensed on the 1st/2nd attempt ONLY when the task verifiably needs a specific capability STATE that capability; `model` requires `provider`, `effort` requires `provider`+`model`; ultracode effort is Opus 4.8+ only. SOLE CHANNEL: while this server is connected this is the ONLY sanctioned way to spawn sub-agents in BOTH orchestration states; harness-native Task/Agent tools are FORBIDDEN. Children run with env SUBAGENT_MCP_SUBAGENT=1 so orchestration hooks skip them (not orchestrators, no carryover re-trigger). Launch returns `processing` (alive); a later `stalled` is alive-but-quiet (thinking or awaiting a temp-file handoff), NOT dead — wait or re-poll, don't kill (see poll_agent). DEADLOCK RULE: you MUST set `deadlock=true` when, and ONLY when, 2 attempts for the SAME atomic task have already failed/been unsatisfactory (the 3rd attempt onward; re-wording or re-splitting does NOT make it a new task), and NEVER otherwise — from the 3rd attempt deadlock outranks any capability override: drop provider/model/effort.", {
499
688
  task_category: z.enum(TASK_CATEGORIES).describe(TASK_CATEGORY_GLOSS),
500
689
  prompt: z.string().min(1),
501
690
  provider: z.enum(["claude", "codex"]).optional(),
@@ -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
- 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.", {
901
+ 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.", {
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. 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`.", {
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
- 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 OFFtoggling 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).", {
1228
+ 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.", {
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
- 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).", {
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. \"smart\" is the DEFAULT (used whenever unset): launch_agent REJECTS any call supplying provider/model/effort and the server auto-picks the best model for the task_category. \"user-approved-overrides\" opens a 30-MINUTE window where selectors are HONORED, enforced LAZILY (reverts to smart on the next launch_agent call after the 30 min elapse); re-enabling does NOT extend an active window. HONOR-BASED, parallel to orchestration-mode: 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; a plain yes/no if neither exists). This tool CANNOT verify that authorization — never enable on your own initiative. PERSISTENCE: state keyed by cwd; both the mode and the override-window timestamp survive server restarts (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
@@ -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
  }
package/dist/zombie.js ADDED
@@ -0,0 +1,189 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { platform } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ export const ZOMBIE_LIVE_IDLE_MS = 6 * 60 * 1000;
6
+ export const ZOMBIE_TERMINAL_IDLE_MS = 30 * 1000;
7
+ export const ZOMBIE_FORCE_GRACE_MS = 20 * 1000;
8
+ export const ZOMBIE_INTENTS_FILENAME = "zombie-intents.jsonl";
9
+ export const ZOMBIE_REPORTS_FILENAME = "zombie-reports.jsonl";
10
+ function numberOrNull(v) {
11
+ return Number.isFinite(v) ? v : null;
12
+ }
13
+ function parseTimeMs(v) {
14
+ if (Number.isFinite(v))
15
+ return v;
16
+ if (typeof v !== "string")
17
+ return null;
18
+ const t = Date.parse(v);
19
+ return Number.isFinite(t) ? t : null;
20
+ }
21
+ export function slotPathForAgent(dir, agentId) {
22
+ return join(dir, `slot-${agentId}.json`);
23
+ }
24
+ export function parseSlotMetadata(text, slotPath = "") {
25
+ let raw;
26
+ try {
27
+ raw = JSON.parse(text);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ const baseAgentId = basename(slotPath).replace(/^slot-/, "").replace(/\.json$/, "");
33
+ const agent_id = typeof raw.agent_id === "string" ? raw.agent_id : baseAgentId;
34
+ const started_at = typeof raw.started_at === "string"
35
+ ? raw.started_at
36
+ : typeof raw.startedAt === "string"
37
+ ? raw.startedAt
38
+ : null;
39
+ const started_at_ms = parseTimeMs(raw.started_at_ms ?? raw.started_at ?? raw.startedAt);
40
+ return {
41
+ schema_version: 1,
42
+ agent_id,
43
+ server_pid: numberOrNull(raw.server_pid ?? raw.pid),
44
+ child_pid: numberOrNull(raw.child_pid),
45
+ cwd: typeof raw.cwd === "string" ? raw.cwd : null,
46
+ started_at,
47
+ started_at_ms,
48
+ last_activity_ms: parseTimeMs(raw.last_activity_ms ?? raw.lastActivity ?? raw.started_at_ms ?? raw.startedAt),
49
+ status: typeof raw.status === "string" ? raw.status : null,
50
+ };
51
+ }
52
+ export function readSlotMetadata(slotPath) {
53
+ try {
54
+ return parseSlotMetadata(readFileSync(slotPath, "utf8"), slotPath);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function writeSlotMetadata(slotPath, metadata) {
61
+ const now = Date.now();
62
+ const full = {
63
+ schema_version: 1,
64
+ agent_id: metadata.agent_id,
65
+ server_pid: metadata.server_pid ?? process.pid,
66
+ child_pid: metadata.child_pid ?? null,
67
+ cwd: metadata.cwd ?? process.cwd(),
68
+ started_at: metadata.started_at ?? new Date(now).toISOString(),
69
+ started_at_ms: metadata.started_at_ms ?? now,
70
+ last_activity_ms: metadata.last_activity_ms ?? now,
71
+ status: metadata.status ?? null,
72
+ };
73
+ writeFileSync(slotPath, JSON.stringify(full), { mode: 0o600 });
74
+ }
75
+ function drainJsonl(path) {
76
+ if (!existsSync(path))
77
+ return [];
78
+ const claim = join(dirname(path), `${basename(path)}.${process.pid}.drain`);
79
+ try {
80
+ renameSync(path, claim);
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ try {
86
+ return readFileSync(claim, "utf8")
87
+ .split(/\r?\n/)
88
+ .filter(Boolean)
89
+ .map((line) => JSON.parse(line));
90
+ }
91
+ finally {
92
+ try {
93
+ unlinkSync(claim);
94
+ }
95
+ catch { }
96
+ }
97
+ }
98
+ export function buildProcessTreeKillCommands(pid, p = platform()) {
99
+ if (p === "win32") {
100
+ return {
101
+ graceful: { command: "taskkill", args: ["/PID", String(pid), "/T"] },
102
+ force: { command: "taskkill", args: ["/PID", String(pid), "/T", "/F"] },
103
+ };
104
+ }
105
+ return {
106
+ graceful: { command: "kill", args: ["-TERM", `-${pid}`] },
107
+ force: { command: "kill", args: ["-KILL", `-${pid}`] },
108
+ };
109
+ }
110
+ export function appendZombieRecord(dir, record) {
111
+ mkdirSync(dir, { recursive: true, mode: 0o1777 });
112
+ const line = `${JSON.stringify(record)}\n`;
113
+ writeFileSync(join(dir, ZOMBIE_INTENTS_FILENAME), line, { flag: "a", mode: 0o600 });
114
+ writeFileSync(join(dir, ZOMBIE_REPORTS_FILENAME), line, { flag: "a", mode: 0o600 });
115
+ }
116
+ export function drainZombieReports(dir) {
117
+ return drainJsonl(join(dir, ZOMBIE_REPORTS_FILENAME));
118
+ }
119
+ export function drainZombieIntents(dir) {
120
+ return drainJsonl(join(dir, ZOMBIE_INTENTS_FILENAME));
121
+ }
122
+ function defaultRunCommand(command, args) {
123
+ execFileSync(command, args, { stdio: "ignore" });
124
+ }
125
+ function defaultSleepMs(ms) {
126
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
127
+ }
128
+ export function cullStaleSlots(dir, deps = {}) {
129
+ const now = deps.now?.() ?? Date.now();
130
+ const runCommand = deps.runCommand ?? defaultRunCommand;
131
+ const sleepMs = deps.sleepMs ?? defaultSleepMs;
132
+ const forceGraceMs = deps.forceGraceMs?.() ?? ZOMBIE_FORCE_GRACE_MS;
133
+ const p = deps.platform ?? platform();
134
+ const records = [];
135
+ let files;
136
+ try {
137
+ files = readdirSync(dir).filter((f) => f.startsWith("slot-") && f.endsWith(".json"));
138
+ }
139
+ catch {
140
+ return records;
141
+ }
142
+ for (const file of files) {
143
+ const slotPath = join(dir, file);
144
+ const meta = readSlotMetadata(slotPath);
145
+ if (!meta?.last_activity_ms)
146
+ continue;
147
+ if (now - meta.last_activity_ms <= ZOMBIE_LIVE_IDLE_MS)
148
+ continue;
149
+ const pid = meta.child_pid;
150
+ if (pid && pid !== process.pid) {
151
+ const commands = buildProcessTreeKillCommands(pid, p);
152
+ try {
153
+ runCommand(commands.graceful.command, commands.graceful.args);
154
+ }
155
+ catch { }
156
+ const forceKill = () => {
157
+ try {
158
+ runCommand(commands.force.command, commands.force.args);
159
+ }
160
+ catch { }
161
+ };
162
+ if (deps.scheduleForceKill) {
163
+ deps.scheduleForceKill(forceGraceMs, forceKill);
164
+ }
165
+ else {
166
+ sleepMs(forceGraceMs);
167
+ forceKill();
168
+ }
169
+ }
170
+ const record = {
171
+ kind: "zombie_killed",
172
+ agent_id: meta.agent_id,
173
+ child_pid: meta.child_pid,
174
+ server_pid: meta.server_pid,
175
+ slot_path: slotPath,
176
+ reason: "stale_live",
177
+ detected_at_ms: now,
178
+ last_activity_ms: meta.last_activity_ms,
179
+ message: `zombies: culled stale subagent ${meta.agent_id}`,
180
+ };
181
+ appendZombieRecord(dir, record);
182
+ try {
183
+ unlinkSync(slotPath);
184
+ }
185
+ catch { }
186
+ records.push(record);
187
+ }
188
+ return records;
189
+ }
package/package.json CHANGED
@@ -1,68 +1,68 @@
1
- {
2
- "name": "@heretyc/subagent-mcp",
3
- "version": "2.10.0",
4
- "description": "MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions (no direct Anthropic/OpenAI API).",
5
- "keywords": [
6
- "mcp",
7
- "model-context-protocol",
8
- "mcp-server",
9
- "claude",
10
- "claude-code",
11
- "codex",
12
- "orchestration",
13
- "subagent",
14
- "sub-agent",
15
- "multi-agent",
16
- "ai-agents",
17
- "anthropic",
18
- "openai"
19
- ],
20
- "type": "module",
21
- "main": "dist/index.js",
22
- "bin": {
23
- "subagent-mcp": "dist/index.js"
24
- },
25
- "files": [
26
- "dist",
27
- "directives",
28
- "scripts/postinstall.mjs",
29
- "LICENSE",
30
- "NOTICE",
31
- "README.md"
32
- ],
33
- "scripts": {
34
- "check:versions": "node scripts/check_version_sync.mjs",
35
- "build": "npm run check:versions && node scripts/gen-ruleset-scaffold.mjs && tsc && node scripts/copy-provider.mjs",
36
- "start": "node dist/index.js",
37
- "postinstall": "node scripts/postinstall.mjs",
38
- "prepare": "npm run build",
39
- "prepublishOnly": "npm test",
40
- "test": "npm run check:versions && node test/effort.test.mjs && node test/drivers.test.mjs && node test/platform.test.mjs && node test/wait.test.mjs && node test/status.test.mjs && node test/output.test.mjs && node test/stream.test.mjs && node test/routing.test.mjs && node test/deadlock.test.mjs && node test/handler-validation.test.mjs && node test/index-handler.test.mjs && node test/ruleset.test.mjs && node test/ruleset-exec.test.mjs && node test/ruleset-handler.test.mjs && node test/failover.test.mjs && node test/global-concurrency-cap.test.mjs && node test/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-pretool.test.mjs && node test/orchestration-directives.test.mjs && node test/no-five-call.test.mjs && node test/check-worktree-subagent.test.mjs && node test/launch-agent-upsert.test.mjs && node test/claude-session-limit.test.mjs && node test/init-migration.test.mjs && node test/init-global.test.mjs && node test/mirror-fragments.test.mjs && node test/performance-tier-effort.test.mjs && node test/setup-repair.test.mjs && node test/setup-quoting.test.mjs && node test/setup-wire.test.mjs && node test/setup-cli-integration.test.mjs && node test/init.test.mjs && node test/model-selection-mode.test.mjs && node test/cli-args.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/mcp-compliance.test.mjs"
41
- },
42
- "author": "Lexi Blackburn",
43
- "license": "Apache-2.0",
44
- "repository": {
45
- "type": "git",
46
- "url": "git+https://github.com/Heretyc/subagent-mcp.git"
47
- },
48
- "homepage": "https://github.com/Heretyc/subagent-mcp#readme",
49
- "bugs": {
50
- "url": "https://github.com/Heretyc/subagent-mcp/issues"
51
- },
52
- "dependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "^0.3.177",
54
- "@modelcontextprotocol/sdk": "^1.0.0",
55
- "zod": "^4.4.3"
56
- },
57
- "devDependencies": {
58
- "@types/node": "^20.0.0",
59
- "typescript": "^5.0.0"
60
- },
61
- "engines": {
62
- "node": ">=18"
63
- },
64
- "publishConfig": {
65
- "registry": "https://registry.npmjs.org",
66
- "access": "public"
67
- }
68
- }
1
+ {
2
+ "name": "@heretyc/subagent-mcp",
3
+ "version": "2.10.2",
4
+ "description": "MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions (no direct Anthropic/OpenAI API).",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "mcp-server",
9
+ "claude",
10
+ "claude-code",
11
+ "codex",
12
+ "orchestration",
13
+ "subagent",
14
+ "sub-agent",
15
+ "multi-agent",
16
+ "ai-agents",
17
+ "anthropic",
18
+ "openai"
19
+ ],
20
+ "type": "module",
21
+ "main": "dist/index.js",
22
+ "bin": {
23
+ "subagent-mcp": "dist/index.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "directives",
28
+ "scripts/postinstall.mjs",
29
+ "LICENSE",
30
+ "NOTICE",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "check:versions": "node scripts/check_version_sync.mjs",
35
+ "build": "npm run check:versions && node scripts/gen-ruleset-scaffold.mjs && tsc && node scripts/copy-provider.mjs",
36
+ "start": "node dist/index.js",
37
+ "postinstall": "node scripts/postinstall.mjs",
38
+ "prepare": "npm run build",
39
+ "prepublishOnly": "npm test",
40
+ "test": "npm run check:versions && node test/effort.test.mjs && node test/drivers.test.mjs && node test/platform.test.mjs && node test/wait.test.mjs && node test/status.test.mjs && node test/output.test.mjs && node test/stream.test.mjs && node test/routing.test.mjs && node test/deadlock.test.mjs && node test/handler-validation.test.mjs && node test/index-handler.test.mjs && node test/ruleset.test.mjs && node test/ruleset-exec.test.mjs && node test/ruleset-handler.test.mjs && node test/failover.test.mjs && node test/global-concurrency-cap.test.mjs && node test/zombie.test.mjs && node test/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-pretool.test.mjs && node test/orchestration-directives.test.mjs && node test/no-five-call.test.mjs && node test/check-worktree-subagent.test.mjs && node test/launch-agent-upsert.test.mjs && node test/claude-session-limit.test.mjs && node test/init-migration.test.mjs && node test/init-global.test.mjs && node test/mirror-fragments.test.mjs && node test/performance-tier-effort.test.mjs && node test/setup-repair.test.mjs && node test/setup-quoting.test.mjs && node test/setup-wire.test.mjs && node test/setup-cli-integration.test.mjs && node test/init.test.mjs && node test/model-selection-mode.test.mjs && node test/cli-args.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/mcp-compliance.test.mjs"
41
+ },
42
+ "author": "Lexi Blackburn",
43
+ "license": "Apache-2.0",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/Heretyc/subagent-mcp.git"
47
+ },
48
+ "homepage": "https://github.com/Heretyc/subagent-mcp#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/Heretyc/subagent-mcp/issues"
51
+ },
52
+ "dependencies": {
53
+ "@anthropic-ai/claude-agent-sdk": "^0.3.177",
54
+ "@modelcontextprotocol/sdk": "^1.0.0",
55
+ "zod": "^4.4.3"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^20.0.0",
59
+ "typescript": "^5.0.0"
60
+ },
61
+ "engines": {
62
+ "node": ">=18"
63
+ },
64
+ "publishConfig": {
65
+ "registry": "https://registry.npmjs.org",
66
+ "access": "public"
67
+ }
68
+ }