@heretyc/subagent-mcp 2.12.1 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,11 @@ queue. You change the number in the `global-concurrency.jsonc` file in the
124
124
  install folder (minimum 10); the file is re-read on every launch, so no restart
125
125
  is needed.
126
126
 
127
+ The same settings file includes `checkForUpdates` (default `true`). When a newer
128
+ npm version exists, the per-turn hook can show a throttled notice to run
129
+ `subagent-mcp update` and then `subagent-mcp setup`. Set `checkForUpdates` to
130
+ `false`, or run with `SUBAGENT_UPDATE_CHECK=0` / `false`, to disable that check.
131
+
127
132
  ## Basic debugging
128
133
 
129
134
  - **"An agent looks stuck."** A quiet agent is usually **still alive**, not
@@ -12,9 +12,9 @@
12
12
  {"provider": "claude", "model": "sonnet", "effort": "high", "rank": 1},
13
13
  {"provider": "codex", "model": "gpt-5.5", "effort": "xhigh", "rank": 2}
14
14
  ]
15
- Valid providers: claude, codex. Valid models: haiku, sonnet, opus, opus-4-8 (claude);
15
+ Valid providers: claude, codex. Valid models: haiku, sonnet, opus, opus-4-8, fable (claude);
16
16
  gpt-5.5 (codex). Valid efforts: haiku -> "none" only; sonnet -> medium|high|xhigh|max;
17
- opus/opus-4-8 -> those plus ultracode; gpt-5.5 -> medium|high|xhigh.
17
+ fable -> medium|high|xhigh|max; opus/opus-4-8 -> those plus ultracode; gpt-5.5 -> medium|high|xhigh.
18
18
  "rank" on output is ignored. An EMPTY array vetoes the launch. Anything else
19
19
  invalid fails the launch hard — the server validates strictly.
20
20
 
@@ -6,6 +6,7 @@ import { CONCURRENCY_SCAFFOLD } from "./config-scaffold.js";
6
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";
7
7
  export const DEFAULT_CAP = 20;
8
8
  export const MIN_CAP = 10;
9
+ export const DEFAULT_CHECK_FOR_UPDATES = true;
9
10
  export const CONFIG_FILENAME = "global-concurrency.jsonc";
10
11
  export { cullStaleSlots, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, parseSlotMetadata, readSlotMetadata, slotPathForAgent, writeSlotMetadata, };
11
12
  export function clampCap(raw) {
@@ -31,6 +32,16 @@ export function parseConcurrencyConfig(text) {
31
32
  }
32
33
  return clampCap(raw);
33
34
  }
35
+ export function parseCheckForUpdatesConfig(text) {
36
+ try {
37
+ const raw = JSON.parse(stripJsoncComments(text))
38
+ ?.checkForUpdates;
39
+ return typeof raw === "boolean" ? raw : DEFAULT_CHECK_FOR_UPDATES;
40
+ }
41
+ catch {
42
+ return DEFAULT_CHECK_FOR_UPDATES;
43
+ }
44
+ }
34
45
  export function defaultConfigPath() {
35
46
  return fileURLToPath(new URL("./" + CONFIG_FILENAME, import.meta.url));
36
47
  }
@@ -51,6 +62,15 @@ export function readGlobalCap(path = defaultConfigPath()) {
51
62
  return DEFAULT_CAP;
52
63
  }
53
64
  }
65
+ export function readCheckForUpdates(path = defaultConfigPath()) {
66
+ try {
67
+ ensureConcurrencyConfig(path);
68
+ return parseCheckForUpdatesConfig(readFileSync(path, "utf8"));
69
+ }
70
+ catch {
71
+ return DEFAULT_CHECK_FOR_UPDATES;
72
+ }
73
+ }
54
74
  export function slotDir() {
55
75
  if (process.env.SUBAGENT_SLOT_DIR)
56
76
  return process.env.SUBAGENT_SLOT_DIR;
@@ -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\n// ------------------------------------------------------------------\n// SOLE source of truth for the machine-wide limit on how many subagents\n// may be ALIVE AT ONCE across EVERY session, process, and user on this\n// machine. There is NO environment-variable override.\n//\n// The whole recursive descendant tree counts toward this ONE number: a\n// subagent that itself launches subagents adds to the same machine-wide\n// total, and OTHER active agentic sessions count too.\n//\n// RE-READ on every launch_agent call — edits take effect immediately, no\n// server restart required.\n//\n// Value rules (forcibly applied to the number below):\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\n// - 1 through 9 -> forced UP to minimum 10\n// - 10 or greater -> used as-is\n//\n// Zombie culling is always enabled. There is no config knob. Before cap\n// rejection, launch/tool/hook paths refresh live owned slots, preserve stale\n// slots whose owner server is still alive, and cull stale slots whose owner is\n// gone. Managed stale slots terminate the child process tree, then force-kill\n// after 20s when needed; unmanaged stale slots are only unlinked.\n//\n// When the cap is reached after culling, launch_agent is REJECTED (never\n// queued). Free a slot with list_agents + kill_agent, then retry.\n{\n \"globalConcurrentSubagents\": 20\n}\n";
2
+ export const CONCURRENCY_SCAFFOLD = "// subagent-mcp — Global Concurrent Subagent Cap\n// ------------------------------------------------------------------\n// SOLE source of truth for the machine-wide limit on how many subagents\n// may be ALIVE AT ONCE across EVERY session, process, and user on this\n// machine. There is NO environment-variable override for the cap.\n//\n// The whole recursive descendant tree counts toward this ONE number: a\n// subagent that itself launches subagents adds to the same machine-wide\n// total, and OTHER active agentic sessions count too.\n//\n// RE-READ on every launch_agent call — edits take effect immediately, no\n// server restart required.\n//\n// Value rules (forcibly applied to the number below):\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\n// - 1 through 9 -> forced UP to minimum 10\n// - 10 or greater -> used as-is\n//\n// Zombie culling is always enabled. There is no config knob. Before cap\n// rejection, launch/tool/hook paths refresh live owned slots, preserve stale\n// slots whose owner server is still alive, and cull stale slots whose owner is\n// gone. Managed stale slots terminate the child process tree, then force-kill\n// after 20s when needed; unmanaged stale slots are only unlinked.\n//\n// When the cap is reached after culling, launch_agent is REJECTED (never\n// queued). Free a slot with list_agents + kill_agent, then retry.\n//\n// checkForUpdates controls the silent npmjs update check started when the MCP\n// server connects. Default true. Set to false to skip the registry fetch and\n// suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.\n{\n \"globalConcurrentSubagents\": 20,\n \"checkForUpdates\": true\n}\n";
package/dist/effort.js CHANGED
@@ -6,6 +6,8 @@ export function mapModel(provider, model) {
6
6
  if (provider === "claude") {
7
7
  if (model === "opus" || model === "opus-4-8")
8
8
  return "claude-opus-4-8";
9
+ if (model === "fable")
10
+ return "claude-fable-5";
9
11
  return model; // haiku, sonnet as-is
10
12
  }
11
13
  else {
@@ -26,7 +28,7 @@ export function resolveEffort(provider, model, effort) {
26
28
  if (provider === "claude" && model === "haiku") {
27
29
  return { kind: "none" };
28
30
  }
29
- if (provider === "claude" && ["sonnet", "opus", "opus-4-8"].includes(model)) {
31
+ if (provider === "claude" && ["sonnet", "opus", "opus-4-8", "fable"].includes(model)) {
30
32
  if (["medium", "high", "xhigh", "max"].includes(effort)) {
31
33
  return { kind: "flag", value: effort };
32
34
  }
@@ -2,7 +2,7 @@
2
2
  // ------------------------------------------------------------------
3
3
  // SOLE source of truth for the machine-wide limit on how many subagents
4
4
  // may be ALIVE AT ONCE across EVERY session, process, and user on this
5
- // machine. There is NO environment-variable override.
5
+ // machine. There is NO environment-variable override for the cap.
6
6
  //
7
7
  // The whole recursive descendant tree counts toward this ONE number: a
8
8
  // subagent that itself launches subagents adds to the same machine-wide
@@ -24,6 +24,11 @@
24
24
  //
25
25
  // When the cap is reached after culling, launch_agent is REJECTED (never
26
26
  // queued). Free a slot with list_agents + kill_agent, then retry.
27
+ //
28
+ // checkForUpdates controls the silent npmjs update check started when the MCP
29
+ // server connects. Default true. Set to false to skip the registry fetch and
30
+ // suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.
27
31
  {
28
- "globalConcurrentSubagents": 20
32
+ "globalConcurrentSubagents": 20,
33
+ "checkForUpdates": true
29
34
  }
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
- import { claimAndEmit, appendHookZombieReport, classifyClaim, countJsonlType, cullHookZombies, runHook, sessionKey, } from "../orchestration/hook-core.js";
3
+ import { claimAndEmit, 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,13 +80,13 @@ export const codexAdapter = {
80
80
  export function runCodexHook(payload, env, adapter = codexAdapter) {
81
81
  try {
82
82
  if (payload.hook_event_name === "SessionStart") {
83
- const zombieRecords = cullHookZombies();
83
+ cullHookZombies();
84
84
  if (adapter.isSubagent(payload, env)) {
85
- return appendHookZombieReport("", zombieRecords);
85
+ return "";
86
86
  }
87
87
  const cwd = payload.cwd || process.cwd();
88
88
  if (!marker.isActive(cwd)) {
89
- return appendHookZombieReport("", zombieRecords);
89
+ return "";
90
90
  }
91
91
  const current = sessionKey(payload);
92
92
  const turn = adapter.currentTurn(payload.transcript_path);
@@ -96,7 +96,7 @@ export function runCodexHook(payload, env, adapter = codexAdapter) {
96
96
  // semantics — FULL + ON reminder, ack-latched CARRYOVER prepend, counter
97
97
  // re-baseline). SessionStart claims even on SAME-SESSION (resume) so
98
98
  // turn 0 is always covered.
99
- return appendHookZombieReport(claimAndEmit(cwd, current, turn, m, kind, env, adapter), zombieRecords);
99
+ return claimAndEmit(cwd, current, turn, m, kind, env, adapter);
100
100
  }
101
101
  // UserPromptSubmit (and any other event) -> normal cadence.
102
102
  return runHook(payload, env, adapter);
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { CONFIG_FILENAME, NONBLOCKING_CULL_DEPS, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_L
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";
25
+ import { checkForNpmUpdate } from "./orchestration/update-check.js";
25
26
  import { ensureParentMarker } from "./launch-prompt.js";
26
27
  const agents = new Map();
27
28
  const deadlockWindow = createDeadlockWindow();
@@ -263,7 +264,7 @@ function withMaintenance(handler, options = {}) {
263
264
  return async (params) => {
264
265
  const zombieRecords = runToolMaintenance();
265
266
  const result = await handler(params, zombieRecords);
266
- if (options.omitZombieReport)
267
+ if (!options.includeZombieReport)
267
268
  return result;
268
269
  if (result && typeof result === "object" && "content" in result) {
269
270
  return withZombieReport(result, zombieRecords);
@@ -441,7 +442,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
441
442
  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.";
442
443
  const server = new McpServer({
443
444
  name: "subagent-mcp",
444
- version: "2.12.1",
445
+ version: "2.12.2",
445
446
  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.",
446
447
  }, {
447
448
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -800,7 +801,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
800
801
  task_category: z.enum(TASK_CATEGORIES).describe(TASK_CATEGORY_GLOSS),
801
802
  prompt: z.string().min(1),
802
803
  provider: z.enum(["claude", "codex"]).optional(),
803
- model: z.enum(["haiku", "sonnet", "opus", "opus-4-8", "gpt-5.5"]).optional(),
804
+ model: z.enum(["haiku", "sonnet", "opus", "opus-4-8", "fable", "gpt-5.5"]).optional(),
804
805
  effort: z.enum(["medium", "high", "xhigh", "max", "ultracode"]).optional(),
805
806
  cwd: z.string().optional(),
806
807
  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."),
@@ -1008,7 +1009,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
1008
1009
  .map((s, i) => ` ${i + 1}. ${s.model}@${s.effort} (${s.provider}) [${s.failure_type}]: ${s.reason}`)
1009
1010
  .join("\n");
1010
1011
  return errorResult(`Error: all ${skipped.length} candidate launches failed for task_category ${task_category}:\n${lines}\n${SPLIT_HINT}\n${AUTO_HINT}`);
1011
- }, { omitZombieReport: true }) // ponytail: launch_agent still reaps, but callers do not receive zombie_report.
1012
+ }) // ponytail: launch_agent still reaps, but callers do not receive zombie_report.
1012
1013
  );
1013
1014
  // Tool 2: poll_agent
1014
1015
  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.", {
@@ -1080,7 +1081,7 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
1080
1081
  },
1081
1082
  ],
1082
1083
  };
1083
- }));
1084
+ }, { includeZombieReport: true }));
1084
1085
  // Tool 3: kill_agent
1085
1086
  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.", {
1086
1087
  agent_id: z.string(),
@@ -1257,7 +1258,7 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
1257
1258
  },
1258
1259
  ],
1259
1260
  };
1260
- }));
1261
+ }, { includeZombieReport: true }));
1261
1262
  // Tool 6: wait
1262
1263
  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`.", {
1263
1264
  verbose: z.boolean().optional().default(false),
@@ -1598,6 +1599,7 @@ if (isMain) {
1598
1599
  // (the tool's enabled:false writes a disable record via writeDisable /
1599
1600
  // writeDisableCwd; it does not call disable().)
1600
1601
  getNpmPrefix();
1602
+ void checkForNpmUpdate().catch(() => { });
1601
1603
  startLivenessHeartbeat();
1602
1604
  const transport = new StdioServerTransport();
1603
1605
  await server.connect(transport);
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join } from "node:path";
5
5
  import * as marker from "./marker.js";
6
6
  import * as reminder from "./reminder.js";
7
7
  import { cullStaleSlots, slotDir, ZOMBIE_FORCE_GRACE_MS, } from "../concurrency.js";
8
+ import { appendUpdateNotice, readInstalledPackageInfo, } from "./update-check.js";
8
9
  /**
9
10
  * Provider-agnostic core of the UserPromptSubmit / SessionStart hook.
10
11
  *
@@ -240,15 +241,13 @@ export function cullHookZombies(deps = hookCullDeps()) {
240
241
  return [];
241
242
  }
242
243
  }
243
- export function hookZombieReportText(records) {
244
- const ids = Array.from(new Set(records.map((r) => r.agent_id))).filter(Boolean);
245
- return ids.length > 0 ? `zombies: ${ids.join(",")}` : "";
246
- }
247
- export function appendHookZombieReport(out, records) {
248
- const report = hookZombieReportText(records);
249
- if (!report)
244
+ function appendHookUpdateNotice(out, current, env) {
245
+ try {
246
+ return appendUpdateNotice(out, readInstalledPackageInfo().version, current, env);
247
+ }
248
+ catch {
250
249
  return out;
251
- return out ? `${out}\n${report}` : report;
250
+ }
252
251
  }
253
252
  /**
254
253
  * Core hook logic. Returns the string to inject, or '' to inject nothing.
@@ -269,28 +268,29 @@ export function appendHookZombieReport(out, records) {
269
268
  */
270
269
  export function runHook(payload, env, adapter) {
271
270
  try {
272
- const zombieRecords = cullHookZombies();
271
+ cullHookZombies();
273
272
  if (adapter.isSubagent(payload, env)) {
274
- return appendHookZombieReport("", zombieRecords);
273
+ return "";
275
274
  }
276
275
  const cwd = payload.cwd || process.cwd();
277
276
  const current = sessionKey(payload);
277
+ const updateNoticeSessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
278
278
  if (current)
279
279
  marker.writeCurrentSession(cwd, current);
280
280
  if (!marker.isActive(cwd, current)) {
281
281
  // OFF: no claim machinery — just the per-prompt reminder cadence.
282
282
  const r = reminder.advance(cwd, current);
283
- return appendHookZombieReport(cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted), zombieRecords);
283
+ return appendHookUpdateNotice(cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted), updateNoticeSessionId, env);
284
284
  }
285
285
  const m = marker.readMarker(cwd);
286
286
  const kind = classifyClaim(m.owner_session, m.baseline_turn, current);
287
287
  if (kind === "fresh" || kind === "carryover") {
288
288
  const turn = adapter.currentTurn(payload.transcript_path);
289
- return appendHookZombieReport(claimAndEmit(cwd, current, turn, m, kind, env, adapter), zombieRecords);
289
+ return appendHookUpdateNotice(claimAndEmit(cwd, current, turn, m, kind, env, adapter), updateNoticeSessionId, env);
290
290
  }
291
291
  // SAME-SESSION: per-prompt reminder cadence, ON variant.
292
292
  const r = reminder.advance(cwd, current);
293
- return appendHookZombieReport(cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted), zombieRecords);
293
+ return appendHookUpdateNotice(cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted), updateNoticeSessionId, env);
294
294
  }
295
295
  catch {
296
296
  // Any failure -> inject nothing. Never crash or stall the host turn.
@@ -1,5 +1,5 @@
1
1
  import { serverAlive } from "./liveness.js";
2
- import { cullHookZombies, hookZombieReportText } from "./hook-core.js";
2
+ import { cullHookZombies } from "./hook-core.js";
3
3
  const NATIVE_SUBAGENT_TOOLS = new Set(["Task", "Agent", "Explore"]);
4
4
  function decision(permissionDecision, permissionDecisionReason, additionalContext) {
5
5
  return {
@@ -22,21 +22,21 @@ function decision(permissionDecision, permissionDecisionReason, additionalContex
22
22
  */
23
23
  export function runClaudePreTool(payload, env, now = Date.now()) {
24
24
  try {
25
- const zombieReport = hookZombieReportText(cullHookZombies());
25
+ const zombieRecords = cullHookZombies();
26
26
  if (env.SUBAGENT_MCP_SUBAGENT === "1")
27
27
  return null;
28
- const zombieAllowedDecision = zombieReport
29
- ? decision("allow", "zombies culled; allowing requested tool.", zombieReport)
28
+ const maintenanceAllowedDecision = zombieRecords.length > 0
29
+ ? decision("allow", "maintenance completed; allowing requested tool.")
30
30
  : null;
31
31
  if (!serverAlive(now))
32
- return zombieAllowedDecision;
32
+ return maintenanceAllowedDecision;
33
33
  const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
34
34
  if (!tool)
35
- return zombieAllowedDecision;
35
+ return maintenanceAllowedDecision;
36
36
  if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
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);
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.");
38
38
  }
39
- return zombieAllowedDecision;
39
+ return maintenanceAllowedDecision;
40
40
  }
41
41
  catch {
42
42
  return null;
@@ -0,0 +1,174 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readCheckForUpdates } from "../concurrency.js";
4
+ import { stateDir } from "./marker.js";
5
+ export const UPDATE_NOTICE_TEXT = "Notice: An improved version of subagent-mcp is available via the CLI command `subagent-mcp update` and can then be fully installed with `subagent-mcp setup`. This will fix security issues and improve user experience.";
6
+ export const UPDATE_CHECK_TIMEOUT_MS = 2500;
7
+ export const UPDATE_NOTICE_INTERVAL_MS = 12 * 60 * 60 * 1000;
8
+ function pendingNoticePath() {
9
+ return join(stateDir, "update-notice.json");
10
+ }
11
+ function emitRecordPath() {
12
+ return join(stateDir, "update-notice-emitted.json");
13
+ }
14
+ function atomicWriteJson(path, value) {
15
+ try {
16
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
17
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
18
+ writeFileSync(tmp, JSON.stringify(value), { encoding: "utf8", mode: 0o600 });
19
+ renameSync(tmp, path);
20
+ }
21
+ catch {
22
+ // Fail-safe: update notices must never affect the host turn or MCP channel.
23
+ }
24
+ }
25
+ function readJson(path) {
26
+ try {
27
+ return JSON.parse(readFileSync(path, "utf8"));
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ function removeFile(path) {
34
+ try {
35
+ rmSync(path, { force: true });
36
+ }
37
+ catch {
38
+ // Fail-safe.
39
+ }
40
+ }
41
+ export function clearUpdateNoticeState() {
42
+ removeFile(pendingNoticePath());
43
+ removeFile(emitRecordPath());
44
+ }
45
+ export function writePendingUpdateNotice(latestVersion, now = Date.now()) {
46
+ atomicWriteJson(pendingNoticePath(), {
47
+ latest_version: latestVersion,
48
+ checked_at: new Date(now).toISOString(),
49
+ });
50
+ }
51
+ export function readPendingUpdateNotice() {
52
+ const parsed = readJson(pendingNoticePath());
53
+ if (parsed &&
54
+ typeof parsed.latest_version === "string" &&
55
+ typeof parsed.checked_at === "string") {
56
+ return { latest_version: parsed.latest_version, checked_at: parsed.checked_at };
57
+ }
58
+ return undefined;
59
+ }
60
+ export function readUpdateNoticeEmitRecord() {
61
+ const parsed = readJson(emitRecordPath());
62
+ if (!parsed || typeof parsed.notified_at !== "number")
63
+ return undefined;
64
+ return {
65
+ notified_at: parsed.notified_at,
66
+ ...(typeof parsed.session_id === "string" ? { session_id: parsed.session_id } : {}),
67
+ };
68
+ }
69
+ function writeUpdateNoticeEmitRecord(record) {
70
+ atomicWriteJson(emitRecordPath(), record);
71
+ }
72
+ export function updateCheckEnvDisabled(env = process.env) {
73
+ const raw = env.SUBAGENT_UPDATE_CHECK;
74
+ return typeof raw === "string" && /^(?:0|false)$/i.test(raw.trim());
75
+ }
76
+ export function shouldCheckForUpdates(env = process.env, configPath) {
77
+ if (updateCheckEnvDisabled(env))
78
+ return false;
79
+ return readCheckForUpdates(configPath);
80
+ }
81
+ export function compareNumericVersions(a, b) {
82
+ const parse = (value) => {
83
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:$|[^\d])/.exec(value.trim());
84
+ return match ? match.slice(1, 4).map((part) => Number.parseInt(part, 10)) : undefined;
85
+ };
86
+ const left = parse(a);
87
+ const right = parse(b);
88
+ if (!left || !right)
89
+ return 0;
90
+ for (let i = 0; i < 3; i++) {
91
+ if (left[i] < right[i])
92
+ return -1;
93
+ if (left[i] > right[i])
94
+ return 1;
95
+ }
96
+ return 0;
97
+ }
98
+ export function isVersionNewer(latest, installed) {
99
+ return compareNumericVersions(installed, latest) < 0;
100
+ }
101
+ export function readInstalledPackageInfo() {
102
+ return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
103
+ }
104
+ function registryPackageUrl(packageName, registryBaseUrl) {
105
+ const base = registryBaseUrl.replace(/\/+$/, "");
106
+ return `${base}/${packageName.replace("/", "%2F")}`;
107
+ }
108
+ async function fetchLatestVersion(packageName, deps) {
109
+ const controller = new AbortController();
110
+ const timeout = setTimeout(() => controller.abort(), deps.timeoutMs);
111
+ try {
112
+ const response = await deps.fetch(registryPackageUrl(packageName, deps.registryBaseUrl), {
113
+ headers: { accept: "application/json" },
114
+ signal: controller.signal,
115
+ });
116
+ if (!response.ok)
117
+ return undefined;
118
+ const metadata = (await response.json());
119
+ return typeof metadata?.["dist-tags"]?.latest === "string"
120
+ ? metadata["dist-tags"].latest
121
+ : undefined;
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ finally {
127
+ clearTimeout(timeout);
128
+ }
129
+ }
130
+ export async function checkForNpmUpdate(deps = {}) {
131
+ try {
132
+ const env = deps.env ?? process.env;
133
+ if (!shouldCheckForUpdates(env, deps.configPath))
134
+ return;
135
+ const pkg = (deps.packageInfo ?? readInstalledPackageInfo)();
136
+ const latest = await fetchLatestVersion(pkg.name, {
137
+ fetch: deps.fetch ?? fetch,
138
+ registryBaseUrl: deps.registryBaseUrl ?? "https://registry.npmjs.org",
139
+ timeoutMs: deps.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS,
140
+ });
141
+ if (latest && isVersionNewer(latest, pkg.version)) {
142
+ writePendingUpdateNotice(latest, deps.now?.() ?? Date.now());
143
+ }
144
+ }
145
+ catch {
146
+ // Silent skip: never throw and never log to MCP stdio.
147
+ }
148
+ }
149
+ export function appendUpdateNotice(out, installedVersion, sessionId, env = process.env, now = Date.now(), configPath) {
150
+ try {
151
+ if (!out || !shouldCheckForUpdates(env, configPath))
152
+ return out;
153
+ const pending = readPendingUpdateNotice();
154
+ if (!pending)
155
+ return out;
156
+ if (!isVersionNewer(pending.latest_version, installedVersion)) {
157
+ removeFile(pendingNoticePath());
158
+ return out;
159
+ }
160
+ const emitted = readUpdateNoticeEmitRecord();
161
+ if (emitted?.session_id && sessionId && emitted.session_id === sessionId)
162
+ return out;
163
+ if (emitted && now - emitted.notified_at < UPDATE_NOTICE_INTERVAL_MS)
164
+ return out;
165
+ writeUpdateNoticeEmitRecord({
166
+ notified_at: now,
167
+ ...(sessionId ? { session_id: sessionId } : {}),
168
+ });
169
+ return `${out}\n${UPDATE_NOTICE_TEXT}`;
170
+ }
171
+ catch {
172
+ return out;
173
+ }
174
+ }
package/dist/routing.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { readFileSync } from "node:fs";
13
13
  import { fileURLToPath } from "node:url";
14
14
  /** Launch model enum accepted by buildCommand. */
15
- export const LAUNCH_MODELS = ["haiku", "sonnet", "opus", "opus-4-8", "gpt-5.5"];
15
+ export const LAUNCH_MODELS = ["haiku", "sonnet", "opus", "opus-4-8", "fable", "gpt-5.5"];
16
16
  /** Launch effort enum accepted by buildCommand/resolveEffort. */
17
17
  export const LAUNCH_EFFORTS = ["medium", "high", "xhigh", "max", "ultracode"];
18
18
  /** Sentinel reported (and passed-through harmlessly) for haiku, whose effort is ignored. */
@@ -28,12 +28,14 @@ const FULL_TO_SHORT = {
28
28
  "claude-opus-4-8": new Set(["opus", "opus-4-8"]),
29
29
  "claude-sonnet-4-6": "sonnet",
30
30
  "claude-haiku-4-5": "haiku",
31
+ "claude-fable-5": "fable",
31
32
  "gpt-5.5": "gpt-5.5",
32
33
  // Short ids may already appear in a hand-authored table; map them through.
33
34
  haiku: "haiku",
34
35
  sonnet: "sonnet",
35
36
  opus: "opus",
36
37
  "opus-4-8": "opus-4-8",
38
+ fable: "fable",
37
39
  };
38
40
  export const DEFAULT_BRANCH = "cost_efficiency";
39
41
  /** Resolve dist/routing-table.json relative to this module at runtime. */
@@ -76,6 +78,7 @@ export function mapModelToProvider(model) {
76
78
  model === "sonnet" ||
77
79
  model === "opus" ||
78
80
  model === "opus-4-8" ||
81
+ model === "fable" ||
79
82
  model.startsWith("claude-")) {
80
83
  return "claude";
81
84
  }
@@ -252,8 +255,8 @@ export function validatePresence(p) {
252
255
  }
253
256
  // provider+model must satisfy the existing match rule.
254
257
  if (provider && model) {
255
- if (provider === "claude" && !["haiku", "sonnet", "opus", "opus-4-8"].includes(model)) {
256
- return `Error: Claude provider only supports haiku, sonnet, opus, or opus-4-8. Got: ${model}`;
258
+ if (provider === "claude" && !["haiku", "sonnet", "opus", "opus-4-8", "fable"].includes(model)) {
259
+ return `Error: Claude provider only supports haiku, sonnet, opus, opus-4-8, or fable. Got: ${model}`;
257
260
  }
258
261
  if (provider === "codex" && model !== "gpt-5.5") {
259
262
  return `Error: Codex provider only supports gpt-5.5. Got: ${model}`;
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/gen-ruleset-scaffold.mjs from src/advanced-ruleset.py — DO NOT EDIT.
2
- export const RULESET_SCAFFOLD = "#!/usr/bin/env python3\n\"\"\"advanced-ruleset.py — final-authority model-routing override hook for subagent-mcp.\n\n(a) PERFORMANCE WARNING: this script runs synchronously inside EVERY launch_agent\n call. Slow rules slow every agent launch. Keep rules lean and low-latency —\n no network calls, no heavy imports at module top. This is YOUR responsibility;\n you have been warned.\n\n(b) OUTPUT CONTRACT (routing mode): print to stdout ONE JSON array — the modified\n candidate list (reorder / filter / replace allowed). Template:\n [\n {\"provider\": \"claude\", \"model\": \"sonnet\", \"effort\": \"high\", \"rank\": 1},\n {\"provider\": \"codex\", \"model\": \"gpt-5.5\", \"effort\": \"xhigh\", \"rank\": 2}\n ]\n Valid providers: claude, codex. Valid models: haiku, sonnet, opus, opus-4-8 (claude);\n gpt-5.5 (codex). Valid efforts: haiku -> \"none\" only; sonnet -> medium|high|xhigh|max;\n opus/opus-4-8 -> those plus ultracode; gpt-5.5 -> medium|high|xhigh.\n \"rank\" on output is ignored. An EMPTY array vetoes the launch. Anything else\n invalid fails the launch hard — the server validates strictly.\n\n(c) INPUT CONTRACT (routing mode, invoked as: <python> advanced-ruleset.py route):\n stdin receives one JSON object:\n { \"candidates\": [ {\"provider\",\"model\",\"effort\",\"rank\"} ... ], # rank 1..N best->worst\n \"context\": { \"task_category\": str, \"cwd\": str,\n \"selection_mode\": \"auto\"|\"provider\"|\"provider_model\"|\"explicit\",\n \"provider\": str|None, \"model\": str|None, \"effort\": str|None } }\n OS environment variables are visible natively (os.environ).\n\nENV-CHECK MODE (no arguments): prints {\"ready\": true|false, \"load-rules\": true|false}.\nRuns once per MCP server process. load-rules false => ruleset silently disabled\nfor the rest of the process. Set LOAD_RULES = True below to activate.\n\"\"\"\nimport json\nimport sys\n\nLOAD_RULES = False\n\n# --- Requirements stub (scaffold itself is stdlib-only) ----------------------\n# List third-party distributions your rules import, e.g.:\n# REQUIREMENTS = [\"requests\", \"pyyaml\"]\n# Install with: <python> -m pip install <name> ...\nREQUIREMENTS = []\n\ndef missing_requirements():\n \"\"\"pip-check helper: returns the REQUIREMENTS entries not importable here.\"\"\"\n import importlib.util\n return [r for r in REQUIREMENTS\n if importlib.util.find_spec(r.replace(\"-\", \"_\")) is None]\n\ndef env_check():\n missing = missing_requirements()\n json.dump({\"ready\": not missing, \"load-rules\": bool(LOAD_RULES)}, sys.stdout)\n\ndef apply_rules(candidates, context):\n \"\"\"YOUR RULES HERE. Default: passthrough (returns the list unchanged).\"\"\"\n return candidates\n\ndef route():\n payload = json.load(sys.stdin)\n out = apply_rules(payload.get(\"candidates\", []), payload.get(\"context\", {}))\n json.dump(out, sys.stdout)\n\nif __name__ == \"__main__\":\n if len(sys.argv) > 1 and sys.argv[1] == \"route\":\n route()\n else:\n env_check()\n";
2
+ export const RULESET_SCAFFOLD = "#!/usr/bin/env python3\n\"\"\"advanced-ruleset.py — final-authority model-routing override hook for subagent-mcp.\n\n(a) PERFORMANCE WARNING: this script runs synchronously inside EVERY launch_agent\n call. Slow rules slow every agent launch. Keep rules lean and low-latency —\n no network calls, no heavy imports at module top. This is YOUR responsibility;\n you have been warned.\n\n(b) OUTPUT CONTRACT (routing mode): print to stdout ONE JSON array — the modified\n candidate list (reorder / filter / replace allowed). Template:\n [\n {\"provider\": \"claude\", \"model\": \"sonnet\", \"effort\": \"high\", \"rank\": 1},\n {\"provider\": \"codex\", \"model\": \"gpt-5.5\", \"effort\": \"xhigh\", \"rank\": 2}\n ]\n Valid providers: claude, codex. Valid models: haiku, sonnet, opus, opus-4-8, fable (claude);\n gpt-5.5 (codex). Valid efforts: haiku -> \"none\" only; sonnet -> medium|high|xhigh|max;\n fable -> medium|high|xhigh|max; opus/opus-4-8 -> those plus ultracode; gpt-5.5 -> medium|high|xhigh.\n \"rank\" on output is ignored. An EMPTY array vetoes the launch. Anything else\n invalid fails the launch hard — the server validates strictly.\n\n(c) INPUT CONTRACT (routing mode, invoked as: <python> advanced-ruleset.py route):\n stdin receives one JSON object:\n { \"candidates\": [ {\"provider\",\"model\",\"effort\",\"rank\"} ... ], # rank 1..N best->worst\n \"context\": { \"task_category\": str, \"cwd\": str,\n \"selection_mode\": \"auto\"|\"provider\"|\"provider_model\"|\"explicit\",\n \"provider\": str|None, \"model\": str|None, \"effort\": str|None } }\n OS environment variables are visible natively (os.environ).\n\nENV-CHECK MODE (no arguments): prints {\"ready\": true|false, \"load-rules\": true|false}.\nRuns once per MCP server process. load-rules false => ruleset silently disabled\nfor the rest of the process. Set LOAD_RULES = True below to activate.\n\"\"\"\nimport json\nimport sys\n\nLOAD_RULES = False\n\n# --- Requirements stub (scaffold itself is stdlib-only) ----------------------\n# List third-party distributions your rules import, e.g.:\n# REQUIREMENTS = [\"requests\", \"pyyaml\"]\n# Install with: <python> -m pip install <name> ...\nREQUIREMENTS = []\n\ndef missing_requirements():\n \"\"\"pip-check helper: returns the REQUIREMENTS entries not importable here.\"\"\"\n import importlib.util\n return [r for r in REQUIREMENTS\n if importlib.util.find_spec(r.replace(\"-\", \"_\")) is None]\n\ndef env_check():\n missing = missing_requirements()\n json.dump({\"ready\": not missing, \"load-rules\": bool(LOAD_RULES)}, sys.stdout)\n\ndef apply_rules(candidates, context):\n \"\"\"YOUR RULES HERE. Default: passthrough (returns the list unchanged).\"\"\"\n return candidates\n\ndef route():\n payload = json.load(sys.stdin)\n out = apply_rules(payload.get(\"candidates\", []), payload.get(\"context\", {}))\n json.dump(out, sys.stdout)\n\nif __name__ == \"__main__\":\n if len(sys.argv) > 1 and sys.argv[1] == \"route\":\n route()\n else:\n env_check()\n";
package/dist/ruleset.js CHANGED
@@ -169,7 +169,7 @@ const CODEX_EFFORTS = LAUNCH_EFFORTS.filter((e) => e !== "ultracode" && e !== "m
169
169
  function effortAllowed(model, effort) {
170
170
  if (model === "haiku")
171
171
  return effort === HAIKU_EFFORT;
172
- if (model === "sonnet")
172
+ if (model === "sonnet" || model === "fable")
173
173
  return SONNET_EFFORTS.includes(effort);
174
174
  if (model === "opus" || model === "opus-4-8") {
175
175
  return LAUNCH_EFFORTS.includes(effort);
@@ -185,7 +185,7 @@ function effortAllowed(model, effort) {
185
185
  *
186
186
  * Per element: string provider/model/effort; provider ∈ {claude, codex};
187
187
  * model ∈ launch enum; provider↔model legality (claude↔{haiku,sonnet,opus,
188
- * opus-4-8}, codex↔gpt-5.5); per-model effort legality incl. haiku→"none".
188
+ * opus-4-8,fable}, codex↔gpt-5.5); per-model effort legality incl. haiku→"none".
189
189
  * Extra keys (incl. rank) are ignored on output; duplicates are allowed (the
190
190
  * attempt loop just tries them in order). An EMPTY array is VALID — it is the
191
191
  * limit case of the allowed filter operation and means "veto the launch"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.12.1",
3
+ "version": "2.12.2",
4
4
  "description": "MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions (no direct Anthropic/OpenAI API).",
5
5
  "keywords": [
6
6
  "mcp",
@@ -38,7 +38,7 @@
38
38
  "postinstall": "node scripts/postinstall.mjs",
39
39
  "prepare": "npm run build",
40
40
  "prepublishOnly": "npm test",
41
- "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/no-per-provider-cap.test.mjs && node test/rag-pointers.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 test/index-guard.test.mjs && node test/drivers-guard.test.mjs && node test/audit-next13-lb3.test.mjs && node test/zombie-guard.test.mjs && node test/concurrency-guard.test.mjs && node test/hook-core-guard.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
+ "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/update-check.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/no-per-provider-cap.test.mjs && node test/rag-pointers.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 test/index-guard.test.mjs && node test/drivers-guard.test.mjs && node test/audit-next13-lb3.test.mjs && node test/zombie-guard.test.mjs && node test/concurrency-guard.test.mjs && node test/hook-core-guard.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"
42
42
  },
43
43
  "author": "Lexi Blackburn",
44
44
  "license": "Apache-2.0",