@heretyc/subagent-mcp 2.8.1 → 2.8.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
@@ -17,7 +17,7 @@ MCP server that launches and manages locally installed `claude` and `codex` CLI
17
17
  - Ultracode mode for Opus 4.8 -- headless activation via `--settings {"ultracode":true}` (see [docs/usage.md](docs/usage.md))
18
18
  - Cross-platform exe resolution (Windows: npm-prefix .exe paths; macOS/Linux: PATH + Homebrew/usr-local fallbacks); immediate `taskkill /t /f` (Windows) / `SIGKILL` (POSIX) force-kill; no graceful shutdown period
19
19
  - stdio MCP transport; built with `@modelcontextprotocol/sdk` + `zod`
20
- - `orchestration-mode` tool — toggles an orchestrator-only directive that the bundled Claude Code / Codex plugin injects every turn via its `UserPromptSubmit` hook (Desktop hosts toggle but do not inject); see [docs/spec/orchestration-mode/_INDEX.md](docs/spec/orchestration-mode/_INDEX.md)
20
+ - `orchestration-mode` tool — toggles orchestrator directives injected by bundled Claude Code / Codex hooks; Claude also gets a deterministic `PreToolUse` gate (Desktop hosts toggle but do not inject); see [docs/spec/orchestration-mode/_INDEX.md](docs/spec/orchestration-mode/_INDEX.md)
21
21
 
22
22
  ---
23
23
 
@@ -39,11 +39,16 @@ npm install -g @heretyc/subagent-mcp
39
39
  subagent-mcp setup
40
40
  ```
41
41
 
42
- `setup` detects which vendors are present, registers the MCP server, and writes the `UserPromptSubmit` hook for orchestration-mode injection. Idempotent — safe to re-run after updates. Pass `--dry-run` to preview.
42
+ `setup` detects which vendors are present, registers the MCP server, and writes orchestration-mode hooks. Idempotent — safe to re-run after updates. Pass `--dry-run` to preview.
43
+
44
+ For consumer projects, run `subagent-mcp init --root /path/to/project` to upsert
45
+ the managed invariant block into `AGENTS.md`, `CLAUDE.md`, and `GEMINI.md`.
46
+ Use `--dry-run` to preview, `--remove` to uninstall the block, and `--force`
47
+ only if you intentionally run inside this source repo.
43
48
 
44
49
  After setup, restart your Claude Code or Codex session. On Codex, run `/hooks` and trust the new hook.
45
50
 
46
- **Updating:** `npm install -g @heretyc/subagent-mcp && subagent-mcp setup`
51
+ **Updating:** `subagent-mcp update && subagent-mcp setup`
47
52
 
48
53
  For manual wiring, developer install from source, Gemini CLI, and Claude Desktop, see [docs/registration.md](docs/registration.md).
49
54
 
@@ -1,17 +1,17 @@
1
- <!-- INJECTED PRE-PROMPT DIRECTIVE — BINDING, NON-NEGOTIABLE -->
2
- <ORCHESTRATION-CARRYOVER priority="CRITICAL" override="NONE">
3
-
4
- ORCHESTRATION MODE was ON at session start — carried over from a PRIOR session
5
- for this project (persists until disabled with permission). Not enabled in THIS
6
- session.
7
-
8
- THIS turn, ONCE:
9
- 1. NOTIFY user it carried over.
10
- 2. ASK keep ON? via AskUserQuestion.
11
- 3. ADVISE fit: long-horizon context-fillingkeep ON; bounded, interactive, or
12
- core-bound to main-session-only MCP → propose OFF.
13
-
14
- Declines → orchestration-mode enabled:false. NEVER disable on own initiative.
15
- After answer: handshake done — do not re-raise.
16
-
17
- </ORCHESTRATION-CARRYOVER>
1
+ <ORCHESTRATION-INVARIANT>
2
+ Sub-agent sessions (first prompt began "<this is a request from a parent process>"): ignore this block.
3
+
4
+ ORCHESTRATION MODE carried over ON from a PRIOR session of this project
5
+ (persists until disabled with permission). Not user-enabled THIS session.
6
+
7
+ THIS turn, ONCE:
8
+ 1. NOTIFY user it carried over.
9
+ 2. ASK keep ON? via AskUserQuestion.
10
+ 3. ADVISE fit: long-horizon context-filling → keep ON; bounded, interactive, or
11
+ core-bound to main-session-only MCP propose OFF.
12
+
13
+ Until answered, the mandates below still bind. Declines → orchestration-mode
14
+ enabled:false. NEVER disable on own initiative. After answer: handshake done —
15
+ do not re-raise.
16
+
17
+ </ORCHESTRATION-INVARIANT>
@@ -1,17 +1,17 @@
1
- <!-- INJECTED PRE-PROMPT DIRECTIVE — BINDING, NON-NEGOTIABLE -->
2
- <ORCHESTRATION-CARRYOVER priority="CRITICAL" override="NONE">
3
-
4
- ORCHESTRATION MODE was ON at session start — carried over from a PRIOR session
5
- for this project (persists until disabled with permission). Not enabled in THIS
6
- session.
7
-
8
- THIS turn, ONCE:
9
- 1. NOTIFY user it carried over.
10
- 2. ASK keep ON? via request-user-input.
11
- 3. ADVISE fit: long-horizon context-fillingkeep ON; bounded, interactive, or
12
- core-bound to main-session-only MCP → propose OFF.
13
-
14
- Declines → orchestration-mode enabled:false. NEVER disable on own initiative.
15
- After answer: handshake done — do not re-raise.
16
-
17
- </ORCHESTRATION-CARRYOVER>
1
+ <ORCHESTRATION-INVARIANT>
2
+ Sub-agent sessions (first prompt began "<this is a request from a parent process>"): ignore this block.
3
+
4
+ ORCHESTRATION MODE carried over ON from a PRIOR session of this project
5
+ (persists until disabled with permission). Not user-enabled THIS session.
6
+
7
+ THIS turn, ONCE:
8
+ 1. NOTIFY user it carried over.
9
+ 2. ASK keep ON? via request-user-input.
10
+ 3. ADVISE fit: long-horizon context-filling → keep ON; bounded, interactive, or
11
+ core-bound to main-session-only MCP propose OFF.
12
+
13
+ Until answered, the mandates below still bind. Declines → orchestration-mode
14
+ enabled:false. NEVER disable on own initiative. After answer: handshake done —
15
+ do not re-raise.
16
+
17
+ </ORCHESTRATION-INVARIANT>
@@ -1 +1 @@
1
- Follow the directives in the last <ORCHESTRATION-REMINDER-INVARIANT> block; if none has appeared yet this session, await it do not infer its content.
1
+ <ORCHESTRATION-INVARIANT> SUBAGENT MANDATES bind THIS turn. 5-CALL RULE APPLIES. SOLE CHANNEL: launch sub-agents ONLY via subagent-mcp. OFF: ask before enabling orchestration. ON replies must start: route: delegate|inline - <reason>. </ORCHESTRATION-INVARIANT>
@@ -1,14 +1,14 @@
1
- <!-- INJECTED PRE-PROMPT DIRECTIVE - BINDING, NON-NEGOTIABLE -->
2
- <SUB-AGENT-INVARIANT priority="CRITICAL" override="NONE">
3
- ORCHESTRATION MODE ON: ORCHESTRATOR; default=DELEGATE.
4
-
5
- INLINE BY RIGHT only for main-session-only capability: user interaction/consent, agent control, tiny audits, final git/PR, irreducible verify loops. State which+why. Direct tool calls spend main context; keep to that set.
6
-
7
- Clarification/consent: subagents surface questions to orchestrator. Orchestrator asks with AskUserQuestion, sends answer back. Question/gate/audit duty does not justify adjacent discovery or edits inline.
8
-
9
- MUST DELEGATE/OFFLOAD: pure compute; implementation/integration edits; mixed tasks split. One MCP-bound step never makes all inline. Payload >50KB or >200 lines -> scratch path (%TEMP% or /tmp), hand off PATH. Orchestrator audits diffs/results before commit.
10
-
11
- CONFLICT ORDER: safety-scope > user instruction this turn > delegate-default. Tool-pin repartitions work, does not suspend mode. IPC=temp scratch files only. Full model/governance: server MCP instructions.
12
-
13
- DISABLE: never on own initiative. Propose via AskUserQuestion only; disable only after explicit user permission.
14
- </SUB-AGENT-INVARIANT>
1
+ <ORCHESTRATION-INVARIANT>
2
+ ORCHESTRATION ON. Delegate-default through subagent-mcp; route EVERY sub-agent launch ONLY via subagent-mcp launch_agent. Inline ONLY main-session-only-capability steps (non-inheritable MCP, interactive/consent, verify reruns); temp scratch-file IPC allowed. Repo/system safety rules outrank this.
3
+ - Ruthlessly preserve orchestrator context, NO EXCEPTIONS.
4
+ - Subagents ask questions through the orchestrator; answers
5
+ return to subagents.
6
+ - Sub-agents use %TEMP%, /tmp/, /TEMP/ for all IPC.
7
+ - NEVER DISABLE ORCHESTRATION: never on own initiative. Propose via AskUserQuestion only; disable only after explicit user permission.
8
+ Orchestration Steps:
9
+ 1. Map out work before orchestrating.
10
+ 2. Decompose into phases which have atomic tasks that are separate subagents. Phases = understand → design → implement → review.
11
+ 3. Execute the phases, dispatching sub-agents and between EACH phase dispatch a quality-review sub-agent to validate all work done.
12
+
13
+ - For Sub-agent sessions ONLY (first prompt began "<this is a request from a parent process>" or env SUBAGENT_MCP_SUBAGENT=1): ignore this block.
14
+ </ORCHESTRATION-INVARIANT>
@@ -1,15 +1,13 @@
1
- <SUB-AGENT-INVARIANT priority="CRITICAL" override="NONE">
2
- SCOPE: if prompt begins "<this is a request from a parent process>", skip rest.
3
-
4
- ORCHESTRATION MODE ON: ORCHESTRATOR; default=DELEGATE.
5
-
6
- INLINE BY RIGHT only for main-session-only capability: user interaction/consent, agent control, tiny audits, final git/PR, irreducible verify loops. State which+why. Direct calls spend main context; keep to that set.
7
-
8
- Clarification/consent: subagents surface questions to orchestrator. Orchestrator asks with request-user-input, sends answer back. Question/gate/audit duty does not justify adjacent discovery or edits inline.
9
-
10
- MUST DELEGATE/OFFLOAD: pure compute; implementation/integration edits; mixed tasks split. One MCP-bound step never makes all inline. Payload >50KB or >200 lines -> scratch path (%TEMP% or /tmp), hand off PATH. Orchestrator audits diffs/results before commit.
11
-
12
- CONFLICT ORDER: safety-scope > user instruction this turn > delegate-default. Tool-pin repartitions work, does not suspend mode. IPC=temp scratch files only. Full governance: server MCP instructions.
13
-
14
- DISABLE: never on own initiative. Propose via request-user-input only; disable only after explicit permission.
15
- </SUB-AGENT-INVARIANT>
1
+ <ORCHESTRATION-INVARIANT>
2
+ SCOPE: this session's first prompt began "<this is a request from a parent process>" or env SUBAGENT_MCP_SUBAGENT=1? Sub-agent session — this ORCHESTRATION-INVARIANT does NOT apply; SKIP this block.
3
+ ORCHESTRATION ON. Delegate-default through subagent-mcp; route EVERY sub-agent launch ONLY via subagent-mcp launch_agent. Inline ONLY main-session-only-capability steps (non-inheritable MCP, interactive/consent, verify reruns); temp scratch-file IPC allowed. Repo/system safety rules outrank this.
4
+ - Ruthlessly preserve orchestrator context, NO EXCEPTIONS.
5
+ - Subagents ask questions through the orchestrator; answers
6
+ return to subagents.
7
+ - Sub-agents use %TEMP%, /tmp/, /TEMP/ for all IPC.
8
+ - NEVER DISABLE ORCHESTRATION: never on own initiative. Propose via request-user-input only; disable only after explicit user permission.
9
+ Orchestration Steps:
10
+ 1. Map out work before orchestrating.
11
+ 2. Decompose into phases which have atomic tasks that are separate subagents. Phases = understand → design → implement → review.
12
+ 3. Execute the phases, dispatching sub-agents and between EACH phase dispatch a quality-review sub-agent to validate all work done.
13
+ </ORCHESTRATION-INVARIANT>
@@ -1,12 +1,9 @@
1
- <!-- INJECTED PER-PROMPT REMINDER — BINDING -->
2
- <ORCHESTRATION-REMINDER-INVARIANT>
3
-
4
- ORCHESTRATION OFF. 5-CALL RULE: if the request likely needs >5 tool calls, OR
5
- your 5th call completes with work remaining — STOP, ask via AskUserQuestion
6
- whether to switch orchestration mode ON. Even while OFF, delegating via
7
- subagent-mcp full-auto routing (per-category, latest benchmark data) is
8
- advisable for well-rounded, durable results do not assume the calling model
9
- is always the best fit. Why this exists: agents grind through large work
10
- without ever considering orchestration. Sub-agent sessions: ignore this block.
11
-
12
- </ORCHESTRATION-REMINDER-INVARIANT>
1
+ <ORCHESTRATION-INVARIANT>
2
+ ZERO EXCEPTIONS SUBAGENT MANDATES:
3
+ 5-CALL RULE: >5 tool calls needed for this request (all turns, no reset), or >5 already spent? Count reads, greps, test runs. If yes do NOT grind on inline - STOP and ask via AskUserQuestion whether to switch orchestration ON. That STOP+ask satisfies this rule; if the user declines, inline is authorized for THIS request - do not re-ask it.
4
+ Why this exists: agents grind through large work without ever considering orchestration.
5
+ ORCHESTRATION OFF - still binding:
6
+ INVARIANT ZERO EXCEPTIONS SOLE CHANNEL: ANY sub-agent launch goes ONLY via the subagent-mcp; harness-native Task/Agent tools are FORBIDDEN while this server is connected.
7
+ Advisory: At all times, delegating via subagent-mcp auto routing is advisable - do not assume your training data is still current.
8
+ For Sub-agent sessions ONLY (first prompt began "<this is a request from a parent process>" or env SUBAGENT_MCP_SUBAGENT=1): ignore this block.
9
+ </ORCHESTRATION-INVARIANT>
@@ -1,12 +1,9 @@
1
- <!-- INJECTED PER-PROMPT REMINDER — BINDING -->
2
- <ORCHESTRATION-REMINDER-INVARIANT>
3
-
4
- ORCHESTRATION OFF. 5-CALL RULE: if the request likely needs >5 tool calls, OR
5
- your 5th call completes with work remaining — STOP, ask via request-user-input
6
- whether to switch orchestration mode ON. Even while OFF, delegating via
7
- subagent-mcp full-auto routing (per-category, latest benchmark data) is
8
- advisable for well-rounded, durable results do not assume the calling model
9
- is always the best fit. Why this exists: agents grind through large work
10
- without ever considering orchestration. Sub-agent sessions: ignore this block.
11
-
12
- </ORCHESTRATION-REMINDER-INVARIANT>
1
+ <ORCHESTRATION-INVARIANT>
2
+ ZERO EXCEPTIONS SUBAGENT MANDATES:
3
+ 5-CALL RULE: >5 tool calls needed for this request (all turns, no reset), or >5 already spent? Count reads, greps, test runs. If yes do NOT grind on inline - STOP and ask via request-user-input whether to switch orchestration ON. That STOP+ask satisfies this rule; if the user declines, inline is authorized for THIS request - do not re-ask it.
4
+ Why this exists: agents grind through large work without ever considering orchestration.
5
+ ORCHESTRATION OFF - still binding:
6
+ INVARIANT ZERO EXCEPTIONS SOLE CHANNEL: ANY sub-agent launch goes ONLY via the subagent-mcp; harness-native Task/Agent tools are FORBIDDEN while this server is connected.
7
+ Advisory: At all times, delegating via subagent-mcp auto routing is advisable - do not assume your training data is still current.
8
+ For Sub-agent sessions ONLY (first prompt began "<this is a request from a parent process>" or env SUBAGENT_MCP_SUBAGENT=1): ignore this block.
9
+ </ORCHESTRATION-INVARIANT>
@@ -1,15 +1,15 @@
1
- <!-- INJECTED PER-PROMPT REMINDER — BINDING -->
2
- <ORCHESTRATION-REMINDER-INVARIANT>
3
-
4
- ORCHESTRATION ON. Delegate-default: decompose; delegate; keep orchestrator
5
- context lean. The 5-CALL RULE is satisfied by delegation.
6
- Subagents may ask for clarification/consent through the orchestrator; answers
7
- return to subagents. Questions, gates, and audits do not justify adjacent
8
- inline discovery/edits. Orchestrator tool use is limited to user interaction,
9
- agent control, tiny audits, final git/PR actions, and irreducible verify loops.
10
- Implementation/integration edits remain delegated by default; audit before
11
- commit.
12
- <SUB-AGENT-INVARIANT> governance applies full model: server MCP instructions.
13
- Sub-agent sessions: ignore this block.
14
-
15
- </ORCHESTRATION-REMINDER-INVARIANT>
1
+ <ORCHESTRATION-INVARIANT>
2
+ ORCHESTRATION ON. Route EVERY sub-agent launch ONLY via subagent-mcp launch_agent. Inline ONLY main-session-only-capability steps; temp scratch-file IPC allowed. Repo/system safety rules outrank this.
3
+ - Delegate-default. 5-CALL RULE is satisfied by delegation.
4
+ EVERY reply starts: route: delegate|inline - <reason>
5
+ - Ruthlessly preserve orchestrator context, NO EXCEPTIONS.
6
+ - Subagents ask questions through the orchestrator; answers
7
+ return to subagents.
8
+ - Sub-agents use %TEMP%, /tmp/, /TEMP/ for all IPC.
9
+ Orchestration Steps:
10
+ 1. Map out work before orchestrating.
11
+ 2. Decompose into phases which have atomic tasks that are separate subagents. Phases = understand → design → implement → review.
12
+ 3. Execute the phases, dispatching sub-agents and between EACH phase dispatch a quality-review sub-agent to validate all work done.
13
+
14
+ - For Sub-agent sessions ONLY (first prompt began "<this is a request from a parent process>" or env SUBAGENT_MCP_SUBAGENT=1): ignore this block.
15
+ </ORCHESTRATION-INVARIANT>
@@ -0,0 +1,41 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
3
+ import { runClaudePreTool, } from "../orchestration/pretool.js";
4
+ /**
5
+ * Claude Code PreToolUse hook entry. Enforces deterministic subagent-mcp
6
+ * routing only while the server heartbeat is fresh; stale/missing heartbeat is
7
+ * fail-open so a broken MCP server never traps the user.
8
+ */
9
+ function readStdin() {
10
+ return new Promise((resolve) => {
11
+ let data = "";
12
+ process.stdin.setEncoding("utf8");
13
+ process.stdin.on("data", (chunk) => {
14
+ data += chunk;
15
+ });
16
+ process.stdin.on("end", () => resolve(data));
17
+ process.stdin.on("error", () => resolve(data));
18
+ });
19
+ }
20
+ async function main() {
21
+ let payload = {};
22
+ try {
23
+ const raw = await readStdin();
24
+ if (raw.trim()) {
25
+ payload = JSON.parse(raw);
26
+ }
27
+ }
28
+ catch {
29
+ payload = {};
30
+ }
31
+ const result = runClaudePreTool(payload, process.env);
32
+ if (result) {
33
+ process.stdout.write(JSON.stringify(result));
34
+ }
35
+ process.exit(0);
36
+ }
37
+ const isMain = process.argv[1] !== undefined &&
38
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
39
+ if (isMain) {
40
+ void main();
41
+ }
@@ -1,6 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { countJsonlType, runHook, } from "../orchestration/hook-core.js";
4
+ import { resetToolCount } from "../orchestration/pretool.js";
4
5
  /**
5
6
  * Claude Code UserPromptSubmit hook entry. Reads the JSON payload from stdin,
6
7
  * runs the provider-agnostic core with the Claude adapter, and writes the
@@ -47,6 +48,17 @@ export const claudeAdapter = {
47
48
  reminderOnFile: "reminder-on.md",
48
49
  reminderOffFile: "reminder-off-claude.md",
49
50
  };
51
+ export function runClaudeHook(payload, env, adapter = claudeAdapter) {
52
+ try {
53
+ if (!adapter.isSubagent(payload, env)) {
54
+ resetToolCount(payload);
55
+ }
56
+ return runHook(payload, env, adapter);
57
+ }
58
+ catch {
59
+ return "";
60
+ }
61
+ }
50
62
  function readStdin() {
51
63
  return new Promise((resolve) => {
52
64
  let data = "";
@@ -71,7 +83,7 @@ async function main() {
71
83
  }
72
84
  let out = "";
73
85
  try {
74
- out = runHook(payload, process.env, claudeAdapter);
86
+ out = runClaudeHook(payload, process.env, claudeAdapter);
75
87
  }
76
88
  catch {
77
89
  out = "";
package/dist/index.js CHANGED
@@ -3,9 +3,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { spawn, spawnSync, execSync } from "child_process";
6
- import { unlinkSync, existsSync, realpathSync, readFileSync } from "node:fs";
6
+ import { unlinkSync, existsSync, realpathSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { randomUUID } from "crypto";
8
8
  import { isAbsolute, basename, join, dirname } from "node:path";
9
+ import { tmpdir } from "node:os";
9
10
  import { pathToFileURL } from "url";
10
11
  import { buildCommand } from "./effort.js";
11
12
  import { resolveExeFor } from "./platform.js";
@@ -17,6 +18,7 @@ import { loadRoutingTable, buildCandidates, validatePresence, TASK_CATEGORIES, A
17
18
  import { createDeadlockWindow } from "./deadlock.js";
18
19
  import { createRulesetGate, RULESET_HARD_FAIL_MSG, } from "./ruleset.js";
19
20
  import * as orchestrationMarker from "./orchestration/marker.js";
21
+ import { startLivenessHeartbeat } from "./orchestration/liveness.js";
20
22
  const agents = new Map();
21
23
  const MAX_CLAUDE = 5;
22
24
  const MAX_CODEX = 5;
@@ -114,13 +116,16 @@ reconcileInterval.unref();
114
116
  // initialize (per the MCP spec the initialize result has an `instructions`
115
117
  // field) rather than re-injecting it on every turn. The bundled per-turn hook
116
118
  // injects only a small compact reminder; this is the durable, full explanation.
117
- const ORCHESTRATION_INSTRUCTIONS = "ORCHESTRATION MODE (orchestration-mode tool). WHAT: per-project toggle for LONG-HORIZON work that would fill the context window if run inline. OPERATING MODEL: when ON, act as a workflow orchestrator, delegate-default decompose and delegate/offload by default. INLINE BY RIGHT: steps bound to main-session-only capability may stay inline (MCP tools sub-agents can't inherit, interactive/consent tools, tight verify loops); state which and why. MUST DELEGATE/OFFLOAD: pure compute and any payload >50KB or >200 lines go via temp scratch-file path handoff (%TEMP% on Windows, /tmp on POSIX); keep the orchestrator context lean. CONFLICT ORDER: safety-scope > user instruction this turn > delegate-default. A user tool-pin re-partitions work; it does not suspend mode. PERSISTENCE: enabling writes a per-project marker that PERSISTS across restarts/sessions until disabled with explicit user permission (does NOT reset on a new session). CARRYOVER: if mode was already ON at session start (inherited), the bundled hook prepends a CARRYOVER notice ONCE per marker; you MUST tell the user it auto-activated, ask whether to keep it ON, and advise whether it fits this session's request. DISABLE: never disable on your own initiative — only with EXPLICIT user permission. You MAY propose disabling when task fit is wrong (bounded, interactive, or MCP-bound). Either way, first explain WHAT mode is and WHY, then request permission via the provider tool: AskUserQuestion on Claude, request-user-input on Codex. Only explicit approval may call orchestration-mode enabled:false; if declined, continue under inline-by-right, ask once per topic, never re-nag. Per-turn injection fires only in CLI hosts loading the bundled hook; desktop hosts toggle the marker but inject nothing (documented degradation).";
119
+ const ORCHESTRATION_INSTRUCTIONS = "BINDING IN BOTH MODES - SOLE CHANNEL: while this server is connected, EVERY sub-agent launch goes through launch_agent; harness Task/Agent tools AND any other spawn path (shell claude -p/codex exec, skills) FORBIDDEN. 5-CALL RULE: >5 tool calls needed for the request (all turns, no reset), or >5 already spent? If yes you MUST use orchestration mode + this server; OFF -> ask the user before enabling, a decline authorizes inline for that request. ORCHESTRATION MODE (orchestration-mode tool). WHAT: per-project toggle for LONG-HORIZON work that would fill the context window inline. OPERATING MODEL: when ON you are a delegate-default orchestrator: decompose, DELEGATE/offload; EVERY reply starts: route: delegate|inline - <reason>. MUST DELEGATE/OFFLOAD: pure compute; any payload >50KB or >200 lines via temp scratch-file path handoff (%TEMP% / /tmp); mixed = SPLIT (one MCP-bound step != whole-task inline); keep the orchestrator context lean. INLINE BY RIGHT - the ONLY exemption: steps bound to main-session-only capability (non-inheritable MCP tools, interactive/consent tools, verify loops = re-run existing checks); state which and why. CONFLICT ORDER: safety-scope > user instruction this turn > 5-CALL RULE/delegate-default. User tool-pin re-partitions work; never suspends mode. PERSISTENCE: per-project marker; survives restarts/sessions until disabled with user permission. CARRYOVER: inherited-ON gets a notice ONCE per marker; you MUST tell the user it auto-activated, ask keep ON?, advise fit. DISABLE: never on own initiative. You MAY propose it on task-fit mismatch (bounded/interactive/MCP-bound): explain WHAT+WHY, ask via AskUserQuestion (Claude) / request-user-input (Codex); only explicit approval may call orchestration-mode enabled:false. Declined -> continue under inline-by-right; ask once per topic, never re-nag. Per-turn injection: CLI hosts with bundled hook only; desktop hosts toggle the marker but inject nothing.";
120
+ 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.";
118
121
  const server = new McpServer({
119
122
  name: "subagent-mcp",
120
123
  version: "2.3.9",
121
124
  description: "Spawns the LOCALLY INSTALLED `claude` and `codex` CLI binaries as child processes. Does NOT call the Anthropic or OpenAI HTTP APIs directly (no API keys, no SDK) and there are no plans to — all model access is via the local CLIs.",
122
125
  }, {
123
- instructions: ORCHESTRATION_INSTRUCTIONS,
126
+ instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
127
+ ? SUBAGENT_INSTRUCTIONS
128
+ : ORCHESTRATION_INSTRUCTIONS,
124
129
  });
125
130
  // Best-effort removal of a candidate's temp ultracode settings file after a
126
131
  // LAUNCH-TIME failure (the agentState is never registered, so the close handler
@@ -389,14 +394,14 @@ function sameTriples(a, b) {
389
394
  return a.every((c, i) => c.provider === b[i].provider && c.model === b[i].model && c.effort === b[i].effort);
390
395
  }
391
396
  // Tool 1: launch_agent
392
- server.tool("launch_agent", "Spawn a sub-agent. AUTO MODE (preferred): pass only `prompt` + `task_category`; the server picks the best provider/model/effort for that category from its routing table, launches the top candidate, and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are OPTIONAL overrides — omit for auto-selection; if you pass `model` you must also pass `provider`, and if you pass `effort` you must pass both `provider` and `model`. Unsure which task_category fits? Don't submit one amorphous task — SPLIT into atomic steps that each map to a single category, one agent per step. ultracode effort is Opus-4.8+ only (induced via a temp `--settings {\"ultracode\":true}` file; the CLI rejects `--effort ultracode`). Each sub-agent is a separate claude/codex CLI child that does NOT inherit this session's MCP servers; children run with env SUBAGENT_MCP_SUBAGENT=1 so the orchestration hooks skip them (they are not orchestrators and don't re-trigger carryover). Launch returns status `processing` (alive); a later `stalled` is alive-but-quiet (thinking or awaiting a temp-file handoff), NOT dead — wait or re-poll, don't kill (see poll_agent). DEADLOCK RULE: you MUST ALWAYS set `deadlock=true` when more than 2 launch attempts have already been made for the SAME atomic task (i.e. the 3rd attempt onward), and NEVER otherwise.", {
397
+ server.tool("launch_agent", "Spawn a sub-agent. AUTO MODE (mandatory first attempt unless an override is licensed below): pass only `prompt` + `task_category` and NO overrides; the server picks the best provider/model/effort for that category from its routing table, launches the top candidate, and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are overrides — licensed on 1st/2nd attempts ONLY when the task verifiably requires a specific capability; STATE that capability when overriding; if you pass `model` you must also pass `provider`, and if you pass `effort` you must pass both `provider` and `model`. SOLE CHANNEL: while this server is connected this tool is the ONLY sanctioned way to spawn sub-agents, in BOTH orchestration states — harness-native Task/Agent tools are FORBIDDEN for sub-agent launches. PROMPT RULE: the FIRST line of every `prompt` MUST be \"<this is a request from a parent process>\" (sub-agent self-identification). Unsure which task_category fits? Don't submit one amorphous task — SPLIT into atomic steps that each map to a single category, one agent per step. ultracode effort is Opus-4.8+ only (induced via a temp `--settings {\"ultracode\":true}` file; the CLI rejects `--effort ultracode`). Each sub-agent is a separate claude/codex CLI child that does NOT inherit this session's MCP servers; children run with env SUBAGENT_MCP_SUBAGENT=1 so the orchestration hooks skip them (they are not orchestrators and don't re-trigger carryover). Launch returns status `processing` (alive); a later `stalled` is alive-but-quiet (thinking or awaiting a temp-file handoff), NOT dead — wait or re-poll, don't kill (see poll_agent). DEADLOCK RULE: you MUST ALWAYS set `deadlock=true` when 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory (the 3rd attempt onward; re-wording or re-splitting the prompt does NOT make it a different task), and NEVER otherwise — from the 3rd attempt deadlock outranks any capability override: drop provider/model/effort.", {
393
398
  task_category: z.enum(TASK_CATEGORIES).describe(TASK_CATEGORY_GLOSS),
394
399
  prompt: z.string().min(1),
395
400
  provider: z.enum(["claude", "codex"]).optional(),
396
401
  model: z.enum(["haiku", "sonnet", "opus", "opus-4-8", "gpt-5.5"]).optional(),
397
402
  effort: z.enum(["low", "medium", "high", "xhigh", "max", "ultracode"]).optional(),
398
403
  cwd: z.string().optional(),
399
- deadlock: z.boolean().optional().describe("MANDATE: ALWAYS set deadlock=true when, and ONLY when, more than 2 launch attempts have already been made for the SAME atomic task the 3rd attempt onward. 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. Passing false is identical to omitting it."),
404
+ 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."),
400
405
  }, async (params) => {
401
406
  const { task_category, provider, model, effort, prompt, deadlock } = params;
402
407
  const agentCwd = params.cwd || process.cwd();
@@ -858,7 +863,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a terminal state
858
863
  };
859
864
  });
860
865
  // Tool 7: orchestration-mode
861
- server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF, omit = query current state. 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 per-project toggle for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as an orchestrator with delegate-default, but steps bound to main-session-only capability stay INLINE BY RIGHT (state which + why). PERSISTENCE: a per-project marker keyed by cwd; absence of the marker = OFF = no injection; once ON it persists across restarts/sessions until a permitted disable (it does NOT reset on a new 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).", {
866
+ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF, 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, and the 5-CALL RULE (>5 tool calls needed for the request — all turns, no reset — or >5 already spent → ON: delegate via the subagent MCP; OFF: ask the user before enabling) binds in BOTH states — toggling OFF lifts neither obligation. 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 per-project toggle for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as an orchestrator with delegate-default, but steps bound to main-session-only capability stay INLINE BY RIGHT (state which + why). PERSISTENCE: a per-project marker keyed by cwd; absence of the marker = OFF = no injection; once ON it persists across restarts/sessions until a permitted disable (it does NOT reset on a new 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).", {
862
867
  enabled: z.boolean().optional(),
863
868
  }, async (params) => {
864
869
  const cwd = process.cwd();
@@ -898,22 +903,26 @@ if (isMain) {
898
903
  "",
899
904
  " (no command) start the MCP stdio server (how vendor CLIs run it)",
900
905
  " setup [--dry-run] wire Claude Code CLI / Codex CLI (--dry-run: preview only)",
906
+ " init, --init [flags]",
907
+ " upsert project instruction-file invariant blocks",
908
+ " flags: --dry-run --remove --force --root <dir> --files <csv> --copilot --cursor",
901
909
  " doctor check install and wiring health",
902
- " --update update to the latest release (npm install -g)",
903
- " --version, -v print the installed version",
904
- " --help, -h show this help",
910
+ " update, --update update to the latest release (npm install -g)",
911
+ " version, --version, -v",
912
+ " print the installed version",
913
+ " help, --help, -h show this help",
905
914
  ].join("\n");
906
915
  // dist/index.js -> ../package.json (the installed package manifest).
907
916
  const readPkg = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
908
- if (arg === "--version" || arg === "-v") {
917
+ if (arg === "version" || arg === "--version" || arg === "-v") {
909
918
  console.log(readPkg().version);
910
919
  process.exit(0);
911
920
  }
912
- if (arg === "--help" || arg === "-h") {
921
+ if (arg === "help" || arg === "--help" || arg === "-h") {
913
922
  console.log(usage);
914
923
  process.exit(0);
915
924
  }
916
- if (arg === "--update") {
925
+ if (arg === "update" || arg === "--update") {
917
926
  const pkg = readPkg();
918
927
  const npmArgs = ["install", "-g", `${pkg.name}@latest`];
919
928
  console.log(`subagent-mcp ${pkg.version} -> npm ${npmArgs.join(" ")}`);
@@ -924,25 +933,70 @@ if (isMain) {
924
933
  // next to node_modules\npm). POSIX spawns the npm executable directly.
925
934
  const { findOnPath, resolveCmdShimNodeScript } = await import("./setup.js");
926
935
  const npm = findOnPath("npm") ?? "npm";
927
- let r;
928
- if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(npm)) {
936
+ const spawnNpm = (args, stdio) => {
929
937
  const sibling = join(dirname(npm), "node_modules", "npm", "bin", "npm-cli.js");
930
- const js = resolveCmdShimNodeScript(npm) ?? (existsSync(sibling) ? sibling : null);
931
- r = js
932
- ? spawnSync(process.execPath, [js, ...npmArgs], { stdio: "inherit" })
933
- : // Last resort: cmd.exe via shell. The arg vector is a fixed literal
934
- // list (safe charset only), so there is no quoting/injection surface.
935
- spawnSync("npm", npmArgs, { stdio: "inherit", shell: true });
938
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(npm)) {
939
+ const js = resolveCmdShimNodeScript(npm) ?? (existsSync(sibling) ? sibling : null);
940
+ return js
941
+ ? spawnSync(process.execPath, [js, ...args], {
942
+ stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : stdio,
943
+ encoding: stdio === "pipe" ? "utf8" : undefined,
944
+ })
945
+ : // Last resort: cmd.exe via shell. The arg vector is a fixed literal
946
+ // list (safe charset only), so there is no quoting/injection surface.
947
+ spawnSync("npm", args, {
948
+ stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : stdio,
949
+ shell: true,
950
+ encoding: stdio === "pipe" ? "utf8" : undefined,
951
+ });
952
+ }
953
+ return spawnSync(npm, args, {
954
+ stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : stdio,
955
+ encoding: stdio === "pipe" ? "utf8" : undefined,
956
+ });
957
+ };
958
+ const npmRoot = spawnNpm(["root", "-g"], "pipe");
959
+ if (npmRoot.error || npmRoot.status !== 0) {
960
+ console.error(`update failed to resolve npm global root: ${npmRoot.error?.message ?? npmRoot.stderr?.toString().trim() ?? "npm root -g failed"}`);
961
+ process.exit(npmRoot.status ?? 1);
936
962
  }
937
- else {
938
- r = spawnSync(npm, npmArgs, { stdio: "inherit" });
963
+ const installRoot = join(npmRoot.stdout.toString().trim(), ...pkg.name.split("/"));
964
+ const rulesetPath = join(installRoot, "dist", "advanced-ruleset.py");
965
+ let previousRuleset = null;
966
+ if (existsSync(rulesetPath)) {
967
+ previousRuleset = readFileSync(rulesetPath);
968
+ const backupPath = join(tmpdir(), `advanced-ruleset.py.bak-update-${Date.now()}`);
969
+ try {
970
+ writeFileSync(backupPath, previousRuleset);
971
+ console.log(`backed up user advanced-ruleset.py to ${backupPath}`);
972
+ }
973
+ catch (e) {
974
+ console.error(`update refused before install: failed to back up advanced-ruleset.py: ${e instanceof Error ? e.message : String(e)}`);
975
+ process.exit(1);
976
+ }
939
977
  }
978
+ const r = spawnNpm(npmArgs, "inherit");
940
979
  if (r.error) {
941
980
  console.error(`update failed to start npm: ${r.error.message}`);
942
981
  process.exit(1);
943
982
  }
944
983
  const code = r.status ?? 1;
945
984
  if (code === 0) {
985
+ if (previousRuleset !== null) {
986
+ try {
987
+ const freshRuleset = existsSync(rulesetPath)
988
+ ? readFileSync(rulesetPath)
989
+ : null;
990
+ if (freshRuleset === null || !previousRuleset.equals(freshRuleset)) {
991
+ writeFileSync(rulesetPath, previousRuleset);
992
+ console.log("restored user advanced-ruleset.py (package update never overwrites user edits)");
993
+ }
994
+ }
995
+ catch (e) {
996
+ console.error(`update failed to restore advanced-ruleset.py: ${e instanceof Error ? e.message : String(e)}`);
997
+ process.exit(1);
998
+ }
999
+ }
946
1000
  console.log("Update complete. Restart your CLI sessions so the MCP server picks up the new build.");
947
1001
  }
948
1002
  process.exit(code);
@@ -952,6 +1006,10 @@ if (isMain) {
952
1006
  await runSetup();
953
1007
  process.exit(0);
954
1008
  }
1009
+ if (arg === "init" || arg === "--init") {
1010
+ const { runInit } = await import("./init.js");
1011
+ process.exit(await runInit());
1012
+ }
955
1013
  if (arg === "doctor") {
956
1014
  const { runDoctor } = await import("./doctor.js");
957
1015
  process.exit(await runDoctor());
@@ -967,6 +1025,7 @@ if (isMain) {
967
1025
  // until disabled with explicit user permission. On a new session the bundled
968
1026
  // hook detects the carried-over marker and prompts the user to confirm.
969
1027
  // (orchestrationMarker.disable is still used by the tool's enabled:false.)
1028
+ startLivenessHeartbeat();
970
1029
  const transport = new StdioServerTransport();
971
1030
  await server.connect(transport);
972
1031
  }
package/dist/init.js ADDED
@@ -0,0 +1,230 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ const BEGIN_RE = /<!-- subagent-mcp:begin[^>]*-->[\s\S]*?<!-- subagent-mcp:end -->/;
4
+ export const INIT_BLOCK = [
5
+ "<!-- subagent-mcp:begin v1 -->",
6
+ "## subagent-mcp invariant - managed block, do not edit between markers",
7
+ "",
8
+ "BINDING WHILE CONNECTED. While the `subagent-mcp` MCP server is connected",
9
+ "and functioning, follow its MCP `instructions` and injected",
10
+ "`<ORCHESTRATION-INVARIANT>` blocks as binding repo guidance — subordinate to",
11
+ "repo/system safety rules in AGENTS.md, and never a ban on inline-by-right",
12
+ "main-session-only steps or temp scratch-file IPC.",
13
+ "",
14
+ "- ABSOLUTE NO EXCEPTION INVARIANT: SOLE CHANNEL: route every sub-agent launch through subagent-mcp `launch_agent`.",
15
+ " NEVER FOR ANY REASON use harness-native sub-agent tools or shell-spawned sub-agents while",
16
+ " subagent-mcp is available.",
17
+ "- 5-CALL RULE: if a task likely needs >5 tool calls, or >5 have been spent with",
18
+ " work remaining, stop inline grinding and delegate/orchestrate via subagent-mcp.",
19
+ "- ESCAPE HATCH: if subagent-mcp is not connected or not functioning this turn,",
20
+ " this block is dormant; work normally and report the outage.",
21
+ "<!-- subagent-mcp:end -->",
22
+ ].join("\n");
23
+ function detectEol(s) {
24
+ const crlf = s.indexOf("\r\n");
25
+ const lf = s.replace(/\r\n/g, "").indexOf("\n");
26
+ return crlf >= 0 && (lf < 0 || crlf <= lf) ? "\r\n" : "\n";
27
+ }
28
+ function normalizeBlock(eol) {
29
+ return INIT_BLOCK.split("\n").join(eol);
30
+ }
31
+ function insertAfterFirstHeading(body, block, eol) {
32
+ const m = body.match(/^# .*(?:\r?\n|$)/m);
33
+ if (!m || m.index === undefined)
34
+ return `${block}${eol}${eol}${body}`;
35
+ const end = m.index + m[0].length;
36
+ return `${body.slice(0, end)}${eol}${block}${eol}${body.slice(end)}`;
37
+ }
38
+ function collapseBlankRuns(s, eol) {
39
+ return s.replace(new RegExp(`(?:${eol}){3,}`, "g"), `${eol}${eol}`);
40
+ }
41
+ function removeManagedBlock(body, eol) {
42
+ const match = body.match(BEGIN_RE);
43
+ const next = collapseBlankRuns(body.replace(BEGIN_RE, ""), eol);
44
+ if (!match || match.index !== 0)
45
+ return next;
46
+ let trimmed = next;
47
+ let removed = 0;
48
+ while (trimmed.startsWith(eol) && removed < 2) {
49
+ trimmed = trimmed.slice(eol.length);
50
+ removed++;
51
+ }
52
+ return trimmed;
53
+ }
54
+ function backupOnce(file) {
55
+ if (!existsSync(file))
56
+ return;
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
58
+ const backup = `${file}.bak-init-${stamp}`;
59
+ copyFileSync(file, backup);
60
+ }
61
+ function atomicWrite(file, data, force) {
62
+ mkdirSync(dirname(file), { recursive: true });
63
+ if (force && existsSync(file))
64
+ chmodSync(file, 0o666);
65
+ const tmp = `${file}.tmp-${process.pid}`;
66
+ writeFileSync(tmp, data, "utf8");
67
+ renameSync(tmp, file);
68
+ }
69
+ export function upsertInitBlock(file, opts = {}) {
70
+ const exists = existsSync(file);
71
+ const original = exists ? readFileSync(file, "utf8") : "";
72
+ const hadBom = original.charCodeAt(0) === 0xfeff;
73
+ const body = hadBom ? original.slice(1) : original;
74
+ const eol = exists ? detectEol(body) : "\n";
75
+ const block = normalizeBlock(eol);
76
+ let next = body;
77
+ let status;
78
+ if (opts.remove) {
79
+ if (!exists || !BEGIN_RE.test(body)) {
80
+ status = "absent";
81
+ }
82
+ else {
83
+ next = removeManagedBlock(body, eol);
84
+ status = "removed";
85
+ }
86
+ }
87
+ else if (!exists) {
88
+ next = `${block}${eol}`;
89
+ status = "created";
90
+ }
91
+ else if (BEGIN_RE.test(body)) {
92
+ const current = body.match(BEGIN_RE)?.[0] ?? "";
93
+ if (current === block) {
94
+ status = "ok";
95
+ }
96
+ else {
97
+ next = body.replace(BEGIN_RE, block);
98
+ status = "updated";
99
+ }
100
+ }
101
+ else {
102
+ next = insertAfterFirstHeading(body, block, eol);
103
+ status = "added";
104
+ }
105
+ const changed = !["ok", "absent"].includes(status);
106
+ if (changed && !opts.dryRun) {
107
+ const out = (hadBom ? "\ufeff" : "") + (next.endsWith(eol) ? next : next + eol);
108
+ backupOnce(file);
109
+ atomicWrite(file, out, opts.force === true);
110
+ }
111
+ return { file, status, changed };
112
+ }
113
+ function parseArgs(args) {
114
+ const parsed = {
115
+ dryRun: false,
116
+ remove: false,
117
+ force: false,
118
+ copilot: false,
119
+ cursor: false,
120
+ root: process.cwd(),
121
+ files: null,
122
+ };
123
+ const readValue = (args, i, flag) => {
124
+ const value = args[i + 1];
125
+ if (value === undefined || value === "" || value.startsWith("--")) {
126
+ throw new Error(`${flag} requires a value`);
127
+ }
128
+ return value;
129
+ };
130
+ for (let i = 0; i < args.length; i++) {
131
+ const a = args[i];
132
+ if (a === "--dry-run")
133
+ parsed.dryRun = true;
134
+ else if (a === "--remove" || a === "--uninstall")
135
+ parsed.remove = true;
136
+ else if (a === "--force")
137
+ parsed.force = true;
138
+ else if (a === "--copilot")
139
+ parsed.copilot = true;
140
+ else if (a === "--cursor")
141
+ parsed.cursor = true;
142
+ else if (a === "--root") {
143
+ parsed.root = readValue(args, i, a);
144
+ i++;
145
+ }
146
+ else if (a === "--files") {
147
+ parsed.files = readValue(args, i, a).split(",").filter(Boolean);
148
+ i++;
149
+ }
150
+ else
151
+ throw new Error(`unknown init argument: ${a}`);
152
+ }
153
+ if (!parsed.root)
154
+ throw new Error("--root requires a directory");
155
+ return parsed;
156
+ }
157
+ function isSelfRepo(root) {
158
+ try {
159
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
160
+ return pkg.name === "@heretyc/subagent-mcp";
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ function targetFiles(root, opts) {
167
+ const resolveTarget = (f) => {
168
+ const target = isAbsolute(f) ? resolve(f) : resolve(root, f);
169
+ const rel = relative(root, target);
170
+ if (rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) {
171
+ return target;
172
+ }
173
+ throw new Error(`--files target escapes --root: ${f}`);
174
+ };
175
+ if (opts.files)
176
+ return opts.files.map(resolveTarget);
177
+ const rel = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];
178
+ if (opts.copilot)
179
+ rel.push(".github/copilot-instructions.md");
180
+ if (opts.cursor)
181
+ rel.push(".cursor/rules/subagent-mcp.mdc");
182
+ return rel.map(resolveTarget);
183
+ }
184
+ export async function runInit(args = process.argv.slice(3)) {
185
+ let opts;
186
+ try {
187
+ opts = parseArgs(args);
188
+ }
189
+ catch (e) {
190
+ console.error(e instanceof Error ? e.message : String(e));
191
+ return 1;
192
+ }
193
+ const root = resolve(opts.root);
194
+ if (isSelfRepo(root) && !opts.force) {
195
+ console.error("Refusing to run init inside the subagent-mcp source repo without --force.");
196
+ console.error("This repo keeps CLAUDE.md/GEMINI.md as thin redirects; use --root for a consumer repo.");
197
+ return 1;
198
+ }
199
+ let files;
200
+ try {
201
+ files = targetFiles(root, opts);
202
+ }
203
+ catch (e) {
204
+ console.error(e instanceof Error ? e.message : String(e));
205
+ return 1;
206
+ }
207
+ const issues = [];
208
+ const results = [];
209
+ for (const file of files) {
210
+ try {
211
+ const r = upsertInitBlock(file, opts);
212
+ results.push(r);
213
+ console.log(`${r.status.padEnd(7)} ${file}`);
214
+ }
215
+ catch (e) {
216
+ const msg = e instanceof Error ? e.message : String(e);
217
+ issues.push(`${file}: ${msg}`);
218
+ console.error(`failed ${file}: ${msg}`);
219
+ }
220
+ }
221
+ if (opts.dryRun)
222
+ console.log("(dry-run: no files written)");
223
+ if (issues.length > 0) {
224
+ console.error("\nInit completed with issues:");
225
+ for (const i of issues)
226
+ console.error(`- ${i}`);
227
+ return 1;
228
+ }
229
+ return results.length > 0 ? 0 : 1;
230
+ }
@@ -11,8 +11,8 @@ import * as reminder from "./reminder.js";
11
11
  * turn) READS the marker here and decides what to inject. The hook now emits in
12
12
  * BOTH marker states, on a per-prompt counter (reminder.ts): every
13
13
  * REMINDER_PERIOD-th prompt injects the LONG mode-specific
14
- * <ORCHESTRATION-REMINDER-INVARIANT> block, every prompt between injects the
15
- * one-line pointer at it. Marker ON adds the claim machinery: the claim turn
14
+ * <ORCHESTRATION-INVARIANT> block, every prompt between injects the
15
+ * one-line rule carrier. Marker ON adds the claim machinery: the claim turn
16
16
  * (fresh enable or carryover re-claim) emits the FULL directive plus the ON
17
17
  * reminder block and re-baselines the counter. (Supersedes LOCKED DECISION 2's
18
18
  * same-session rel%5 FULL re-emission — owner directive 2026-06-11: steady
@@ -174,10 +174,10 @@ export function classifyClaim(owner_session, baseline_turn, current) {
174
174
  }
175
175
  /**
176
176
  * Per-prompt reminder cadence emission: the LONG block (longFile) on every
177
- * REMINDER_PERIOD-th counted prompt, the one-line pointer between. When the
177
+ * REMINDER_PERIOD-th counted prompt, the one-line rule carrier between. When the
178
178
  * counter could NOT persist, emit the LONG block — fail VISIBLE: a host whose
179
- * temp dir cannot hold the state file would otherwise inject the pointer on
180
- * every prompt at a block that never arrives.
179
+ * temp dir cannot hold the state file would otherwise inject only the compact
180
+ * rule carrier on every prompt and never refresh the LONG block.
181
181
  */
182
182
  function cadenceEmit(env, adapter, longFile, count, persisted) {
183
183
  return !persisted || count % REMINDER_PERIOD === 0
@@ -218,14 +218,14 @@ export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
218
218
  * does not advance).
219
219
  * 2. marker not active for cwd -> OFF cadence: advance the session's counter
220
220
  * (per-owner; a new session starts its own), LONG OFF-variant reminder when
221
- * count % REMINDER_PERIOD === 0, else the one-line pointer.
221
+ * count % REMINDER_PERIOD === 0, else the one-line rule carrier.
222
222
  * 3. marker active: classify the claim from marker state.
223
223
  * 4. FRESH / CARRYOVER -> claimAndEmit (FULL + ON reminder; CARRYOVER notice
224
224
  * prepended once per marker; counter re-baselined). The transcript turn is
225
225
  * read ONLY here — claim turns are the only consumer of the baseline, and
226
226
  * the tail read is too expensive for the per-prompt steady state.
227
227
  * 5. SAME-SESSION -> ON cadence: LONG ON-variant reminder when
228
- * count % REMINDER_PERIOD === 0, else the one-line pointer.
228
+ * count % REMINDER_PERIOD === 0, else the one-line rule carrier.
229
229
  */
230
230
  export function runHook(payload, env, adapter) {
231
231
  try {
@@ -0,0 +1,32 @@
1
+ import { mkdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { stateDir } from "./marker.js";
4
+ export const LIVENESS_TTL_MS = 120_000;
5
+ export const LIVENESS_INTERVAL_MS = 30_000;
6
+ export function alivePath() {
7
+ return join(stateDir, "alive.flag");
8
+ }
9
+ export function touchAlive(now = Date.now()) {
10
+ try {
11
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
12
+ writeFileSync(alivePath(), `${now}\n`, { encoding: "utf8", mode: 0o600 });
13
+ }
14
+ catch {
15
+ // Hooks fail open when liveness cannot be observed.
16
+ }
17
+ }
18
+ export function serverAlive(now = Date.now()) {
19
+ try {
20
+ const s = statSync(alivePath());
21
+ return now - s.mtimeMs <= LIVENESS_TTL_MS;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export function startLivenessHeartbeat() {
28
+ touchAlive();
29
+ const t = setInterval(() => touchAlive(), LIVENESS_INTERVAL_MS);
30
+ t.unref();
31
+ return t;
32
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { cwdHash, stateDir } from "./marker.js";
5
+ import { serverAlive } from "./liveness.js";
6
+ import { sessionKey } from "./hook-core.js";
7
+ export const INLINE_TOOL_LIMIT = 5;
8
+ const NATIVE_SUBAGENT_TOOLS = new Set(["Task", "Agent", "Explore"]);
9
+ const QUESTION_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
10
+ const SUBAGENT_MCP_TOOL_RE = /(^|__)subagent[-_]?mcp(__|$).*(launch_agent|poll_agent|wait|list_agents|send_message|kill_agent)$/i;
11
+ function hash(s) {
12
+ return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
13
+ }
14
+ function owner(payload) {
15
+ return sessionKey(payload) ?? "null";
16
+ }
17
+ function countPath(payload) {
18
+ const cwd = payload.cwd || process.cwd();
19
+ return join(stateDir, `pretool-${cwdHash(cwd)}-${hash(owner(payload))}.json`);
20
+ }
21
+ function readCount(payload) {
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(countPath(payload), "utf8"));
24
+ return typeof parsed.count === "number" && parsed.count >= 0 ? parsed.count : 0;
25
+ }
26
+ catch {
27
+ return 0;
28
+ }
29
+ }
30
+ function writeCount(payload, count) {
31
+ try {
32
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
33
+ writeFileSync(countPath(payload), JSON.stringify({ owner: owner(payload), count, updated_at: Date.now() }), { encoding: "utf8", mode: 0o600 });
34
+ }
35
+ catch {
36
+ // Fail open: counter persistence is advisory enforcement, not host safety.
37
+ }
38
+ }
39
+ export function resetToolCount(payload) {
40
+ try {
41
+ if (existsSync(countPath(payload)))
42
+ unlinkSync(countPath(payload));
43
+ }
44
+ catch {
45
+ // Fail open.
46
+ }
47
+ }
48
+ function decision(permissionDecision, permissionDecisionReason, additionalContext) {
49
+ return {
50
+ hookSpecificOutput: {
51
+ hookEventName: "PreToolUse",
52
+ permissionDecision,
53
+ permissionDecisionReason,
54
+ ...(additionalContext ? { additionalContext } : {}),
55
+ },
56
+ };
57
+ }
58
+ function isSubagentMcpTool(tool) {
59
+ return (SUBAGENT_MCP_TOOL_RE.test(tool) ||
60
+ [
61
+ "launch_agent",
62
+ "poll_agent",
63
+ "wait",
64
+ "list_agents",
65
+ "send_message",
66
+ "kill_agent",
67
+ ].includes(tool));
68
+ }
69
+ function exemptFromCounter(tool) {
70
+ return isSubagentMcpTool(tool) || QUESTION_TOOLS.has(tool);
71
+ }
72
+ export function runClaudePreTool(payload, env, now = Date.now()) {
73
+ try {
74
+ if (env.SUBAGENT_MCP_SUBAGENT === "1")
75
+ return null;
76
+ if (!serverAlive(now))
77
+ return null;
78
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
79
+ if (!tool)
80
+ return null;
81
+ if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
82
+ 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.");
83
+ }
84
+ if (exemptFromCounter(tool))
85
+ return null;
86
+ const next = readCount(payload) + 1;
87
+ writeCount(payload, next);
88
+ if (next <= INLINE_TOOL_LIMIT)
89
+ return null;
90
+ return decision("ask", `5-CALL RULE: this is tool call ${next} for the current user request. If work remains, delegate through subagent-mcp launch_agent; allow only for main-session-only capability or tight verification.`, "5-CALL RULE reached. Route remaining non-main-session work through subagent-mcp launch_agent; inline only for main-session-only capability or tight verification.");
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
package/dist/routing.js CHANGED
@@ -241,7 +241,7 @@ export function validatePresence(p) {
241
241
  }
242
242
  // 2. deadlock cannot be combined with provider/model/effort.
243
243
  if (deadlock === true && (provider || model || effort)) {
244
- return `Error: deadlock cannot be combined with provider, model, or effort. If repeated attempts at this task have failed, switch to pure auto mode — pass only prompt + task_category and let the server select unless your assignment explicitly demands a specific model. Omit provider/model/effort and retry.\n${AUTO_HINT}`;
244
+ return `Error: deadlock cannot be combined with provider, model, or effort. From the 3rd attempt for the same atomic task, deadlock outranks capability overrides: drop provider/model/effort and retry.\n${AUTO_HINT}`;
245
245
  }
246
246
  // 3. effort present must come with provider AND model (checked before model rule).
247
247
  if (effort && !(provider && model)) {
package/dist/setup.js CHANGED
@@ -34,6 +34,7 @@ export function serverPaths(root = INSTALL_ROOT) {
34
34
  return {
35
35
  server: `${f}/dist/index.js`,
36
36
  claudeHook: `${f}/dist/hooks/orchestration-claude.js`,
37
+ claudePreToolHook: `${f}/dist/hooks/orchestration-claude-pretool.js`,
37
38
  codexHook: `${f}/dist/hooks/orchestration-codex.js`,
38
39
  };
39
40
  }
@@ -61,37 +62,53 @@ export function findOnPath(cmd, env = process.env, platform = process.platform)
61
62
  return null;
62
63
  }
63
64
  /**
64
- * Reconcile the UserPromptSubmit hook in a parsed ~/.claude/settings.json.
65
+ * Reconcile the UserPromptSubmit and PreToolUse hooks in a parsed ~/.claude/settings.json.
65
66
  * Mutates `s` in place. Exact wiring present -> ok. A hook referencing
66
67
  * orchestration-claude.js at any OTHER path/shape -> repaired (rewritten to the
67
68
  * canonical exec form). Absent -> added. Unrelated hooks are never touched.
68
69
  */
69
- export function reconcileClaudeSettings(s, hookPath) {
70
+ export function reconcileClaudeSettings(s, hookPath, preToolHookPath = hookPath.replace(/orchestration-claude\.js$/, "orchestration-claude-pretool.js")) {
70
71
  const hooksBlock = (s.hooks ?? {});
71
72
  s.hooks = hooksBlock;
72
- const upsList = (hooksBlock.UserPromptSubmit ?? []);
73
- hooksBlock.UserPromptSubmit = upsList;
74
- for (const grp of upsList) {
75
- for (const hk of grp.hooks ?? []) {
76
- if (!JSON.stringify(hk).includes("orchestration-claude.js"))
77
- continue;
78
- const args = hk.args;
79
- const exact = hk.command === "node" &&
80
- Array.isArray(args) &&
81
- args.length === 1 &&
82
- args[0] === hookPath;
83
- if (exact)
84
- return { changed: false, status: "ok" };
85
- hk.type = "command";
86
- hk.command = "node";
87
- hk.args = [hookPath];
88
- return { changed: true, status: "repaired" };
73
+ const reconcile = (event, scriptName, desired) => {
74
+ const list = (hooksBlock[event] ?? []);
75
+ hooksBlock[event] = list;
76
+ for (const grp of list) {
77
+ for (const hk of grp.hooks ?? []) {
78
+ if (!JSON.stringify(hk).includes(scriptName))
79
+ continue;
80
+ const args = hk.args;
81
+ const exact = hk.type === desired.type &&
82
+ hk.command === desired.command &&
83
+ Array.isArray(args) &&
84
+ JSON.stringify(args) === JSON.stringify(desired.args) &&
85
+ (desired.timeout === undefined || hk.timeout === desired.timeout);
86
+ if (exact)
87
+ return { changed: false, status: "ok" };
88
+ Object.assign(hk, desired);
89
+ return { changed: true, status: "repaired" };
90
+ }
89
91
  }
90
- }
91
- upsList.push({
92
- hooks: [{ type: "command", command: "node", args: [hookPath] }],
92
+ list.push({ hooks: [{ ...desired }] });
93
+ return { changed: true, status: "added" };
94
+ };
95
+ const prompt = reconcile("UserPromptSubmit", "orchestration-claude.js", {
96
+ type: "command",
97
+ command: "node",
98
+ args: [hookPath],
99
+ });
100
+ const pretool = reconcile("PreToolUse", "orchestration-claude-pretool.js", {
101
+ type: "command",
102
+ command: "node",
103
+ args: [preToolHookPath],
104
+ timeout: 5,
93
105
  });
94
- return { changed: true, status: "added" };
106
+ const status = prompt.status === "repaired" || pretool.status === "repaired"
107
+ ? "repaired"
108
+ : prompt.status === "added" || pretool.status === "added"
109
+ ? "added"
110
+ : "ok";
111
+ return { changed: prompt.changed || pretool.changed, status };
95
112
  }
96
113
  /**
97
114
  * Reconcile the user-scope MCP server entry in a parsed ~/.claude.json.
@@ -211,6 +228,7 @@ export function verifyInstall(root = INSTALL_ROOT) {
211
228
  "dist/index.js",
212
229
  "dist/advanced-ruleset.py",
213
230
  "dist/hooks/orchestration-claude.js",
231
+ "dist/hooks/orchestration-claude-pretool.js",
214
232
  "dist/hooks/orchestration-codex.js",
215
233
  "directives/orchestration-claude.md",
216
234
  "directives/orchestration-codex.md",
@@ -358,8 +376,9 @@ function repairPromptFor(vendor, problem) {
358
376
  `The install root is "${fwd(INSTALL_ROOT)}". Please repair my Claude Code wiring: ` +
359
377
  `(1) register a user-scope MCP server named "subagent-mcp" running ` +
360
378
  `the global bin shim "subagent-mcp" (use 'claude mcp add subagent-mcp subagent-mcp -s user' or edit the mcpServers ` +
361
- `key in ~/.claude.json), and (2) ensure ~/.claude/settings.json has a ` +
362
- `hooks.UserPromptSubmit entry {type:"command", command:"node", args:["${p.claudeHook}"]}. ` +
379
+ `key in ~/.claude.json), and (2) ensure ~/.claude/settings.json has ` +
380
+ `hooks.UserPromptSubmit -> {type:"command", command:"node", args:["${p.claudeHook}"]} and ` +
381
+ `hooks.PreToolUse -> {type:"command", command:"node", args:["${p.claudePreToolHook}"], timeout:5}. ` +
363
382
  `Back up any file before editing it.`);
364
383
  }
365
384
  return (`subagent-mcp setup hit a problem on my machine: ${problem}. ` +
@@ -474,7 +493,7 @@ function wireClaude() {
474
493
  catch (e) {
475
494
  fail("claude", `could not register the MCP server: ${e.message}`);
476
495
  }
477
- // 2) UserPromptSubmit hook in ~/.claude/settings.json.
496
+ // 2) UserPromptSubmit and PreToolUse hooks in ~/.claude/settings.json.
478
497
  try {
479
498
  const sfile = join(homedir(), ".claude", "settings.json");
480
499
  const s = readJson(sfile, {});
@@ -483,7 +502,7 @@ function wireClaude() {
483
502
  backup(sfile);
484
503
  writeFileSync(sfile, JSON.stringify(s, null, 2));
485
504
  }
486
- describe(status, "UserPromptSubmit hook");
505
+ describe(status, "UserPromptSubmit + PreToolUse hooks");
487
506
  if (changed && DRY_RUN)
488
507
  console.log(" (dry-run: not written)");
489
508
  }
@@ -572,7 +591,7 @@ export function verifyWiring(root = INSTALL_ROOT, repair = false) {
572
591
  detail: registrationDetail(registered, attemptedRepair),
573
592
  });
574
593
  results.push({
575
- label: "claude: UserPromptSubmit hook",
594
+ label: "claude: UserPromptSubmit + PreToolUse hooks",
576
595
  ok: hk.status === "ok",
577
596
  detail: hk.status === "ok" ? "wired" : `${hk.status === "repaired" ? "stale path" : "not wired"} - run: subagent-mcp setup`,
578
597
  });
@@ -588,7 +607,7 @@ export function verifyWiring(root = INSTALL_ROOT, repair = false) {
588
607
  detail: srv.status === "ok" ? "registered (file fallback)" : "config stale — run: subagent-mcp setup",
589
608
  });
590
609
  results.push({
591
- label: "claude: UserPromptSubmit hook",
610
+ label: "claude: UserPromptSubmit + PreToolUse hooks",
592
611
  ok: hk.status === "ok",
593
612
  detail: hk.status === "ok" ? "wired" : `${hk.status === "repaired" ? "stale path" : "not wired"} - run: subagent-mcp setup`,
594
613
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.8.1",
3
+ "version": "2.8.2",
4
4
  "description": "MCP server that launches and manages local Claude Code and Codex CLI sub-agents as child processes (no direct Anthropic/OpenAI API).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "postinstall": "node scripts/postinstall.mjs",
22
22
  "prepare": "npm run build",
23
23
  "prepublishOnly": "npm test",
24
- "test": "node test/effort.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/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-directives.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/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"
24
+ "test": "node test/effort.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/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/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/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"
25
25
  },
26
26
  "author": "Lexi Blackburn",
27
27
  "license": "Apache-2.0",
@@ -71,7 +71,7 @@ try {
71
71
  // Detected vendors — concrete, so the user knows what setup will touch.
72
72
  if (hasClaude || hasCodex) {
73
73
  line(" Detected on this machine:");
74
- if (hasClaude) line(" - Claude Code CLI (will get MCP server + UserPromptSubmit hook)");
74
+ if (hasClaude) line(" - Claude Code CLI (will get MCP server + UserPromptSubmit/PreToolUse hooks)");
75
75
  if (hasCodex) line(" - Codex CLI (will get MCP server + SessionStart/UserPromptSubmit hooks)");
76
76
  } else {
77
77
  line(" No Claude Code or Codex CLI detected yet. Install one,");