@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 +1 -23
- package/directives/orchestration-claude.md +6 -8
- package/directives/orchestration-codex.md +6 -8
- package/directives/reminder-on.md +3 -5
- package/directives/short-on.md +1 -1
- package/dist/concurrency.js +15 -3
- package/dist/config-scaffold.js +1 -1
- package/dist/global-concurrency.jsonc +6 -7
- package/dist/hooks/orchestration-codex.js +9 -6
- package/dist/index.js +218 -27
- package/dist/orchestration/hook-core.js +35 -4
- package/dist/orchestration/pretool.js +9 -4
- package/dist/status-helpers.js +2 -2
- package/dist/wait-helpers.js +1 -1
- package/dist/zombie.js +189 -0
- package/package.json +68 -68
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
package/directives/short-on.md
CHANGED
|
@@ -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>
|
package/dist/concurrency.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
70
|
-
|
|
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 {
|
package/dist/config-scaffold.js
CHANGED
|
@@ -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//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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.
|
|
148
|
-
description: "Launches always-interactive local Claude and Codex sub-agent sessions. Claude
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
32
|
+
return zombieAllowedDecision;
|
|
28
33
|
const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
|
|
29
34
|
if (!tool)
|
|
30
|
-
return
|
|
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
|
|
39
|
+
return zombieAllowedDecision;
|
|
35
40
|
}
|
|
36
41
|
catch {
|
|
37
42
|
return null;
|
package/dist/status-helpers.js
CHANGED
|
@@ -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 =
|
package/dist/wait-helpers.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|