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