@heretyc/subagent-mcp 2.9.0 → 2.10.0

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
@@ -1,70 +1,115 @@
1
1
  # subagent-mcp
2
2
 
3
- MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions. Runs on **macOS, Linux, and Windows**.
3
+ [![npm version](https://img.shields.io/npm/v/@heretyc/subagent-mcp?label=npm)](https://www.npmjs.com/package/@heretyc/subagent-mcp)
4
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
5
+ [![node](https://img.shields.io/node/v/@heretyc/subagent-mcp)](https://www.npmjs.com/package/@heretyc/subagent-mcp)
6
+ [![CI](https://github.com/Heretyc/subagent-mcp/actions/workflows/claude-routine.yml/badge.svg)](https://github.com/Heretyc/subagent-mcp/actions/workflows/claude-routine.yml)
4
7
 
5
- **No direct API calls.** subagent-mcp does NOT use the Anthropic or OpenAI HTTP APIs and has no plans to. Claude sessions use the Claude Agent SDK against your local Claude Code executable; Codex sessions use your local `codex app-server`. No API keys.
6
-
7
- **License:** Apache-2.0 | **Author:** Lexi Blackburn | **Repo:** https://github.com/Heretyc/subagent-mcp
8
+ MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions on macOS, Linux, and Windows. No direct API calls. No API keys.
8
9
 
9
10
  ---
10
11
 
11
- ## Features
12
+ ## Install
12
13
 
13
- - Start Claude or Codex interactive sessions as managed sub-agents from any MCP host
14
- - Poll status, stream stdout/stderr tails, and enqueue follow-up messages to live sessions
15
- - Concurrency caps: 5 concurrent Claude agents + 5 concurrent Codex agents (counts only actively-streaming `processing` agents, to limit API rate-limit pressure; quiet `stalled` agents don't reserve a slot)
16
- - Liveness tracking via the visible provider stream (Claude SDK events, Codex app-server JSONL): agents with no parsed visible provider stream item for 10 minutes enter `stalled` state (still alive, just quiet -- thinking or awaiting a temp-file handoff), and recover to `processing` if the visible stream resumes
17
- - Ultracode mode for Opus 4.8 -- headless activation via `--settings {"ultracode":true}` (see [docs/usage.md](docs/usage.md))
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
- - stdio MCP transport; built with `@modelcontextprotocol/sdk` + `zod`
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)
14
+ ### Prerequisites (runtime)
21
15
 
22
- ---
16
+ To run the published CLI you need:
23
17
 
24
- ## Quick Start
18
+ - Node.js >= 18 (`node --version`)
19
+ - `claude` CLI — globally installed and authenticated (`claude --version`)
20
+ - `codex` CLI — globally installed and authenticated (`codex --version`; optional if you only use Claude paths)
25
21
 
26
- **Prerequisites:** Node.js >= 18, plus the `claude` and/or `codex` CLIs installed globally and authenticated.
22
+ Building from source needs additional developer tooling see [CONTRIBUTING.md § Prerequisites](CONTRIBUTING.md#prerequisites).
27
23
 
28
- Installed via [GitHub Packages](https://github.com/Heretyc/subagent-mcp/pkgs/npm/subagent-mcp). One-time `.npmrc` setup required (GitHub Packages requires auth even for public packages):
24
+ ### npmjs (default)
25
+
26
+ No authentication, no `.npmrc` configuration, and no PAT required. The package is publicly available on [npmjs.com](https://www.npmjs.com/package/@heretyc/subagent-mcp).
29
27
 
30
28
  ```bash
31
- # 1. Configure registry for @heretyc scope (once per machine)
32
- echo "@heretyc:registry=https://npm.pkg.github.com" >> ~/.npmrc
29
+ npm install -g @heretyc/subagent-mcp
30
+ ```
33
31
 
34
- # 2. Authenticate use a classic PAT with read:packages scope
32
+ **Use this path for all standard installs.**
33
+
34
+ ### GitHub Packages (org-internal pin / supply-chain auditing)
35
+
36
+ Use this path when your organization's `.npmrc` already routes the `@heretyc` scope through GitHub Packages, when you need supply-chain audit tracing against the org-internal artifact, or when you run inside a GitHub Actions workflow whose `GITHUB_TOKEN` carries `read:packages`. Otherwise prefer npmjs above — it is simpler and needs no credentials.
37
+
38
+ ```bash
39
+ # One-time machine setup
40
+ echo "@heretyc:registry=https://npm.pkg.github.com" >> ~/.npmrc
35
41
  echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_PAT" >> ~/.npmrc
42
+ # PAT must be a classic PAT with the read:packages scope
36
43
 
37
- # 3. Install and wire
38
44
  npm install -g @heretyc/subagent-mcp
45
+ ```
46
+
47
+ > **Note:** GitHub Packages requires authentication even for public packages. If you see `401 Unauthorized`, verify your PAT has the `read:packages` scope and has not expired. GitHub Packages does not render a README page — the live documentation appears on [npmjs.com](https://www.npmjs.com/package/@heretyc/subagent-mcp).
48
+
49
+ ---
50
+
51
+ ## Quick Start
52
+
53
+ **1. Install** (see [Install](#install) above):
54
+
55
+ ```bash
56
+ npm install -g @heretyc/subagent-mcp
57
+ ```
58
+
59
+ **2. Register the MCP server + orchestration hooks:**
60
+
61
+ ```bash
39
62
  subagent-mcp setup
40
63
  ```
41
64
 
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.
65
+ Installing the package only ships the binary — it does **not** auto-wire anything. `subagent-mcp setup` auto-detects Claude Code / Codex and registers the MCP server plus the per-turn orchestration-mode hooks. Preview with `subagent-mcp setup --dry-run`.
43
66
 
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.
67
+ **3. Restart your host.**
48
68
 
49
- To install the block into the providers' **official global user-config files**
50
- instead — `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md` —
51
- run `subagent-mcp init --global`. It honors `--dry-run`/`--remove`/`--force`
52
- and is mutually exclusive with `--root`/`--files`/`--copilot`/`--cursor`. See
53
- [docs/registration.md](docs/registration.md).
69
+ Restart your Claude Code or Codex session so it picks up the new binary. On Codex, run `/hooks` and trust the new hook.
54
70
 
55
- After setup, restart your Claude Code or Codex session. On Codex, run `/hooks` and trust the new hook.
71
+ **4. Initialize the orchestration invariant (recommended):**
56
72
 
57
- **Updating:** `subagent-mcp update && subagent-mcp setup`
73
+ ```bash
74
+ subagent-mcp init --global
75
+ ```
76
+
77
+ `init --global` writes the managed invariant block into the providers' official global user-config files — `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md` — set up once, works across every project. For a single project instead, use `subagent-mcp init --root /path/to/project` to write into that project's instruction files. `--global` and `--root` are mutually exclusive.
78
+
79
+ For manual wiring, Gemini CLI, Claude Desktop, and developer install from source, see [docs/registration.md](docs/registration.md).
80
+
81
+ ---
82
+
83
+ ## Features
84
+
85
+ - Start Claude or Codex interactive sessions as managed sub-agents from any MCP host
86
+ - Poll status, stream stdout/stderr tails, and enqueue follow-up messages to live sessions
87
+ - Concurrency caps: 5 concurrent Claude + 5 concurrent Codex agents (only `processing` agents count toward the cap; `stalled` agents do not hold a slot)
88
+ - Liveness tracking via the visible provider stream (Claude SDK events, Codex app-server JSONL): agents with no parsed visible provider stream item for 10 minutes enter `stalled` state (still alive, just quiet — thinking or awaiting a temp-file handoff), and recover to `processing` if the visible stream resumes
89
+ - Ultracode mode for Opus 4.8 via `--settings {"ultracode":true}` — see [docs/usage.md](docs/usage.md)
90
+ - 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
91
+ - stdio MCP transport; built with `@modelcontextprotocol/sdk` + `zod`
92
+ - `orchestration-mode` tool — toggles orchestrator directives injected by bundled Claude Code / Codex hooks; Claude also gets a deterministic `PreToolUse` gate
93
+
94
+ ---
58
95
 
59
- For manual wiring, developer install from source, Gemini CLI, and Claude Desktop, see [docs/registration.md](docs/registration.md).
96
+ ## Orchestration Mode
97
+
98
+ **ON:** the agent operates as an orchestrator — hook injection governs each session turn, preventing inline reads or writes, and all work is delegated through sub-agent tools.
99
+
100
+ **OFF:** the agent operates normally, with no orchestration constraints.
101
+
102
+ Toggle with the `orchestration-mode` tool. Desktop hosts toggle the mode but receive no hook injection. See [docs/spec/dev-loop/orchestration-directive-architecture.md](docs/spec/dev-loop/orchestration-directive-architecture.md) for full semantics.
60
103
 
61
104
  ---
62
105
 
63
106
  ## Auto Mode
64
107
 
108
+ Pass `task_category` to `launch_agent` and the server picks the best provider, model, and effort for that category, with automatic fallback.
109
+
65
110
  `launch_agent` supports **auto mode**: pass `prompt` + `task_category` and the server picks the best provider/model/effort for that category from its routing table, silently falling back to the next-best candidate on any launch-time failure.
66
111
 
67
- `provider`, `model`, and `effort` are optional overrides — omit them to get auto-selected best combination. Rules: passing `model` requires `provider`; passing `effort` requires both `provider` and `model`.
112
+ `provider`, `model`, and `effort` are optional overrides — omit them to get the auto-selected best combination. Rules: passing `model` requires `provider`; passing `effort` requires both `provider` and `model`.
68
113
 
69
114
  **task_category** (required) — pick one:
70
115
 
@@ -92,20 +137,36 @@ The last four are **composite-inferred**: they carry no dedicated benchmark and
92
137
 
93
138
  ---
94
139
 
140
+ ## Configuration
141
+
142
+ ### Global concurrent-subagent cap
143
+
144
+ subagent-mcp enforces a machine-global cap on the number of **live concurrent subagents** across all agentic sessions and MCP server processes on the machine. The count includes agents started by other active sessions and the whole recursive descendant tree; slots free as agents finish or are killed.
145
+
146
+ The cap is configured in `global-concurrency.jsonc`, a dedicated dist-sibling file in the install directory. It is separate from the advanced routing directives file, ships as a commented template, and is retained across installs and updates by the same preserve-user-edits mechanism as the advanced routing directives.
147
+
148
+ Set `globalConcurrentSubagents` in that file. The default is `20`; the minimum valid value is `10`. Validation is forced: `0`, unset, missing, or invalid values reset to `20`, and values `1` through `9` are pinned up to `10`. There is no environment-variable override; the file is the sole source of truth. The file is re-read on every `launch_agent` call, so edits take effect on the next launch with no server restart.
149
+
150
+ When the cap is reached, `launch_agent` is rejected immediately; it never queues. Because the count includes other active sessions and the entire descendant tree, free slots manually with `list_agents` and `kill_agent` before retrying. There is no automatic cleanup or zombie reaping.
151
+
152
+ ---
153
+
95
154
  ## Tools
96
155
 
97
- Six tools are exposed over the stdio MCP transport:
156
+ Eight tools are exposed over the stdio MCP transport.
98
157
 
99
- | Tool | Purpose |
100
- |------|---------|
101
- | `launch_agent` | Start a new `claude`/`codex` sub-agent session |
102
- | `poll_agent` | Get status + output tail of one agent |
103
- | `kill_agent` | Immediately force-kill any live agent |
104
- | `send_message` | Enqueue a user message on a live session |
105
- | `list_agents` | List all agents with token-efficient core metrics |
106
- | `wait` | Block until one or more agents finish, or 15-minute timeout |
158
+ | Tool | Description |
159
+ |---|---|
160
+ | `launch_agent` | Start a new sub-agent session with a prompt and optional task category / overrides |
161
+ | `poll_agent` | Check status and stream tail output for a running agent |
162
+ | `kill_agent` | Terminate a running or stalled agent |
163
+ | `send_message` | Enqueue a message into a running agent's stdin |
164
+ | `list_agents` | List all tracked agents and their current status |
165
+ | `wait` | Block until a specific agent reaches a terminal state |
166
+ | `orchestration-mode` | Toggle orchestration-mode ON/OFF; controls whether hook directives are injected into agent sessions |
167
+ | `model-selection-mode` | Control model selection: `smart` (auto-pick) or `user-approved-overrides` (30-min override window) |
107
168
 
108
- Full parameters, return shapes, the `alive` / `idle_seconds` / `hint` / `recent_stream` fields, and `poll_agent`'s last-3 visible-stream items are in [docs/tools.md](docs/tools.md).
169
+ Full parameters, return shapes, and the `alive` / `idle_seconds` / `hint` / `recent_stream` fields are in [docs/tools.md](docs/tools.md).
109
170
 
110
171
  ---
111
172
 
@@ -114,30 +175,33 @@ Full parameters, return shapes, the `alive` / `idle_seconds` / `hint` / `recent_
114
175
  Each agent transitions through these states:
115
176
 
116
177
  | Status | Meaning |
117
- |--------|---------|
118
- | `processing` | Driver alive with a visible provider-stream heartbeat in the last 10 minutes -- actively working. Launch time counts as the initial heartbeat |
119
- | `stalled` | Driver STILL ALIVE but no parsed visible provider stream item for >= 10 minutes -- working, thinking, or awaiting a temp-file handoff (not a failure). Recovers to `processing` if the visible stream resumes |
178
+ |---|---|
179
+ | `processing` | Driver alive with a visible provider-stream heartbeat in the last 10 minutes actively working. Launch time counts as the initial heartbeat |
180
+ | `stalled` | Driver STILL ALIVE but no parsed visible provider stream item for >= 10 minutes working, thinking, or awaiting a temp-file handoff (not a failure). Recovers to `processing` if the visible stream resumes |
120
181
  | `finished` | Current turn completed, or driver exited with code 0 |
121
182
  | `errored` | Process exited with non-zero code |
122
183
  | `stopped` | Terminated by `kill_agent` |
123
184
 
124
- `processing` and `stalled` are live. `finished` is reportable for `wait`; if `alive` is still true, `send_message` can start the next turn on the same session. A health monitor runs every 10 seconds, and `poll_agent`/`list_agents` additionally reconcile driver exit synchronously. `wait` does not return just because an agent is `stalled`. `stalled` agents recover to `processing` if the visible stream resumes and are never auto-killed -- prefer `wait`/re-poll over `kill_agent`. Full semantics: [docs/reference/status-lifecycle.md](docs/reference/status-lifecycle.md).
185
+ `processing` and `stalled` are live. `stalled` means the driver is alive but quiet (>= 10 min no visible stream); it recovers automatically and is never auto-killed do not `kill_agent` a stalled agent. `wait` does not return on `stalled`. Full semantics: [docs/reference/status-lifecycle.md](docs/reference/status-lifecycle.md).
125
186
 
126
187
  ---
127
188
 
128
189
  ## Documentation
129
190
 
130
- - [docs/registration.md](docs/registration.md) -- per-platform registration (Claude Code, Codex, Gemini), prerequisites, install, config paths.
131
- - [docs/tools.md](docs/tools.md) -- full Tool Reference for all six tools, including `alive` / `idle_seconds` / `hint` fields.
132
- - [docs/usage.md](docs/usage.md) -- model & effort matrix, ultracode mechanism, underlying CLI invocations, usage examples.
133
- - [docs/SPEC.md](docs/SPEC.md) -- full technical specification (architecture, schemas, status lifecycle, error catalogue).
134
- - [docs/spec/interactive-drivers.md](docs/spec/interactive-drivers.md) -- always-interactive Claude/Codex driver model.
135
- - [docs/release-notes.md](docs/release-notes.md) -- operator-facing release notes (current: **v2.9.0**).
191
+ | Document | Contents |
192
+ |---|---|
193
+ | [docs/registration.md](docs/registration.md) | Per-platform setup: Claude Code, Codex, Gemini CLI, Claude Desktop; manual wiring; developer install from source |
194
+ | [docs/tools.md](docs/tools.md) | Full tool reference — all eight tools, parameters, return shapes |
195
+ | [docs/usage.md](docs/usage.md) | Model & effort matrix, ultracode mode, CLI invocations, usage examples |
196
+ | [docs/SPEC.md](docs/SPEC.md) | Full technical specification architecture, schemas, status lifecycle, error catalogue |
197
+ | [docs/spec/interactive-drivers.md](docs/spec/interactive-drivers.md) | Always-interactive Claude/Codex driver model |
198
+ | [docs/release-notes.md](docs/release-notes.md) | Operator-facing release notes |
199
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Contributor guide — dev environment, build, test, publish |
136
200
 
137
201
  ---
138
202
 
139
203
  ## License
140
204
 
141
- Apache-2.0 -- Copyright 2026 Lexi Blackburn
205
+ Apache-2.0 Copyright 2026 Lexi Blackburn
142
206
 
143
- See [docs/SPEC.md](docs/SPEC.md) for the full technical specification.
207
+ See [LICENSE](LICENSE).
@@ -2,9 +2,9 @@
2
2
  <subagent-mcp state="on" kind="carryover">
3
3
  FIRST-LINE EXEMPTION: if this session's prompt's literal first line begins with "<this is a request from a parent process>", ignore this entire tag.
4
4
 
5
- Orchestration ON carried over from a PRIOR session for this project (persists until disabled with user permission). Not enabled THIS session.
5
+ Orchestration ON carried over from a PRIOR session for this project (per-session disable; next session resumes ON, or after a 2h backstop). Not enabled THIS session.
6
6
 
7
- THIS turn, ONCE: (1) NOTIFY the user it carried over; (2) ASK via AskUserQuestion whether to keep it ON; (3) ADVISE fit — long-horizon / context-filling keep ON; bounded / interactive → propose OFF. Decline → orchestration-mode enabled:false. NEVER disable on your own initiative. After the answer the handshake is done; do not re-raise.
7
+ THIS turn, ONCE: (1) NOTIFY the user it carried over; (2) ASK via AskUserQuestion whether to REMAIN enabled; (3) ADVISE fit — long-horizon → remain enabled; bounded/interactive → disable this session. Decline → orchestration-mode enabled:false this session only; no mid-session re-enable. NEVER disable on your own initiative. After answer handshake done; do not re-raise.
8
8
 
9
9
  While ON, follow the MOST RECENT <subagent-mcp state="on"> tag in context (directive or reminder/carrier); if none is in the current window, the CLAUDE/AGENTS/GEMINI INIT_BLOCK governs. This tag is co-supreme with safety-scope; conflict → ask the user.
10
10
  </subagent-mcp>
@@ -2,9 +2,7 @@
2
2
  <subagent-mcp state="on" kind="carryover">
3
3
  FIRST-LINE EXEMPTION: if this session's prompt's literal first line begins with "<this is a request from a parent process>", ignore this entire tag.
4
4
 
5
- Orchestration ON carried over from a PRIOR session for this project (persists until disabled with user permission). Not enabled THIS session.
5
+ Orchestration ON carried over from a PRIOR session for this project (per-session disable only; the next new session resumes ON automatically or after a 2h backstop). Not enabled THIS session.
6
6
 
7
- THIS turn, ONCE: (1) NOTIFY the user it carried over; (2) ASK via request-user-input whether to keep it ON; (3) ADVISE fit — long-horizon / context-filling → keep ON; bounded / interactive → propose OFF. Decline → orchestration-mode enabled:false. NEVER disable on your own initiative. After the answer the handshake is done; do not re-raise.
8
-
9
- While ON, follow the MOST RECENT <subagent-mcp state="on"> tag in context (directive or reminder/carrier); if none is in the current window, the CLAUDE/AGENTS/GEMINI INIT_BLOCK governs. This tag is co-supreme with safety-scope; conflict → ask the user.
7
+ THIS turn, ONCE: (1) NOTIFY the user it carried over; (2) ASK via request-user-input whether to REMAIN enabled; (3) ADVISE fit — long-horizon / context-filling → remain enabled; bounded / interactive → propose OFF. Decline → orchestration-mode enabled:false for this session only; no mid-session re-enable. NEVER disable on your own initiative. After the answer the handshake is done; do not re-raise.
10
8
  </subagent-mcp>
@@ -0,0 +1,96 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { platform } from "node:os";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CONCURRENCY_SCAFFOLD } from "./config-scaffold.js";
6
+ export const DEFAULT_CAP = 20;
7
+ export const MIN_CAP = 10;
8
+ export const CONFIG_FILENAME = "global-concurrency.jsonc";
9
+ export function clampCap(raw) {
10
+ if (!Number.isInteger(raw))
11
+ return DEFAULT_CAP;
12
+ const v = raw;
13
+ if (v <= 0)
14
+ return DEFAULT_CAP;
15
+ if (v < MIN_CAP)
16
+ return MIN_CAP;
17
+ return v;
18
+ }
19
+ export function stripJsoncComments(text) {
20
+ return text.replace(/^\s*\/\/.*$/gm, "");
21
+ }
22
+ export function parseConcurrencyConfig(text) {
23
+ let raw;
24
+ try {
25
+ raw = JSON.parse(stripJsoncComments(text))?.globalConcurrentSubagents;
26
+ }
27
+ catch {
28
+ raw = undefined;
29
+ }
30
+ return clampCap(raw);
31
+ }
32
+ export function defaultConfigPath() {
33
+ return fileURLToPath(new URL("./" + CONFIG_FILENAME, import.meta.url));
34
+ }
35
+ export function ensureConcurrencyConfig(path = defaultConfigPath()) {
36
+ try {
37
+ if (existsSync(path))
38
+ return;
39
+ writeFileSync(path, CONCURRENCY_SCAFFOLD);
40
+ }
41
+ catch { }
42
+ }
43
+ export function readGlobalCap(path = defaultConfigPath()) {
44
+ try {
45
+ ensureConcurrencyConfig(path);
46
+ return parseConcurrencyConfig(readFileSync(path, "utf8"));
47
+ }
48
+ catch {
49
+ return DEFAULT_CAP;
50
+ }
51
+ }
52
+ export function slotDir() {
53
+ if (platform() === "win32") {
54
+ return join(process.env.ProgramData || process.env.ALLUSERSPROFILE || "C:\\ProgramData", "subagent-mcp", "slots");
55
+ }
56
+ return "/tmp/subagent-mcp/slots";
57
+ }
58
+ export function countSlots(dir = slotDir()) {
59
+ try {
60
+ return readdirSync(dir).filter((f) => f.startsWith("slot-")).length;
61
+ }
62
+ catch {
63
+ return 0;
64
+ }
65
+ }
66
+ export function reserveSlot(agentId, max, dir = slotDir()) {
67
+ try {
68
+ mkdirSync(dir, { recursive: true, mode: 0o1777 });
69
+ const slotPath = join(dir, `slot-${agentId}.json`);
70
+ writeFileSync(slotPath, JSON.stringify({ pid: process.pid, cwd: process.cwd(), startedAt: new Date().toISOString() }), { mode: 0o600 });
71
+ const n = countSlots(dir);
72
+ if (n > max) {
73
+ try {
74
+ unlinkSync(slotPath);
75
+ }
76
+ catch { }
77
+ return { ok: false, current: n - 1, max };
78
+ }
79
+ return { ok: true, slotPath, current: n, max };
80
+ }
81
+ catch (e) {
82
+ console.error(`[concurrency] reserve failed, failing open: ${e instanceof Error ? e.message : String(e)}`);
83
+ return { ok: true, current: 0, max, slotPath: null };
84
+ }
85
+ }
86
+ export function releaseSlot(slotPath) {
87
+ if (!slotPath)
88
+ return;
89
+ try {
90
+ unlinkSync(slotPath);
91
+ }
92
+ catch { }
93
+ }
94
+ export function globalCapMessage(current, max, configPath = defaultConfigPath()) {
95
+ return `Global concurrent-subagent limit reached: ${current} of ${max} live subagents are already running across all sessions on this machine. This global count includes agents started by OTHER active agentic sessions and the ENTIRE recursive descendant tree, not just this session's direct children. launch_agent was REJECTED — this cap never queues or blocks; no slot frees itself by waiting. Free a slot manually first: call list_agents to see live agents, then kill_agent to terminate ones you no longer need, and retry. The limit is "globalConcurrentSubagents" in ${configPath} (default 20, minimum 10).`;
96
+ }
@@ -0,0 +1,2 @@
1
+ // GENERATED by scripts/gen-ruleset-scaffold.mjs from src/global-concurrency.jsonc — DO NOT EDIT.
2
+ export const CONCURRENCY_SCAFFOLD = "// subagent-mcp — Global Concurrent Subagent Cap\r\n// ------------------------------------------------------------------\r\n// SOLE source of truth for the machine-wide limit on how many subagents\r\n// may be ALIVE AT ONCE across EVERY session, process, and user on this\r\n// machine. There is NO environment-variable override.\r\n//\r\n// The whole recursive descendant tree counts toward this ONE number: a\r\n// subagent that itself launches subagents adds to the same machine-wide\r\n// total, and OTHER active agentic sessions count too.\r\n//\r\n// RE-READ on every launch_agent call — edits take effect immediately, no\r\n// server restart required.\r\n//\r\n// Value rules (forcibly applied to the number below):\r\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\r\n// - 1 through 9 -> forced UP to minimum 10\r\n// - 10 or greater -> used as-is\r\n//\r\n// When the cap is reached, launch_agent is REJECTED (never queued). Free a\r\n// slot with list_agents + kill_agent, then retry.\r\n//\r\n// Slots free automatically as agents finish or are killed. There is NO\r\n// zombie reaping. If a server process CRASHES, its slots stay counted until\r\n// you delete the stale slot files by hand:\r\n// Windows: %ProgramData%\\subagent-mcp\\slots\r\n// macOS/Linux: /tmp/subagent-mcp/slots\r\n{\r\n \"globalConcurrentSubagents\": 20\r\n}\r\n";
@@ -0,0 +1,29 @@
1
+ // subagent-mcp — Global Concurrent Subagent Cap
2
+ // ------------------------------------------------------------------
3
+ // SOLE source of truth for the machine-wide limit on how many subagents
4
+ // may be ALIVE AT ONCE across EVERY session, process, and user on this
5
+ // machine. There is NO environment-variable override.
6
+ //
7
+ // The whole recursive descendant tree counts toward this ONE number: a
8
+ // subagent that itself launches subagents adds to the same machine-wide
9
+ // total, and OTHER active agentic sessions count too.
10
+ //
11
+ // RE-READ on every launch_agent call — edits take effect immediately, no
12
+ // server restart required.
13
+ //
14
+ // Value rules (forcibly applied to the number below):
15
+ // - missing / unset / non-integer / 0 / negative -> reset to default 20
16
+ // - 1 through 9 -> forced UP to minimum 10
17
+ // - 10 or greater -> used as-is
18
+ //
19
+ // When the cap is reached, launch_agent is REJECTED (never queued). Free a
20
+ // slot with list_agents + kill_agent, then retry.
21
+ //
22
+ // Slots free automatically as agents finish or are killed. There is NO
23
+ // zombie reaping. If a server process CRASHES, its slots stay counted until
24
+ // you delete the stale slot files by hand:
25
+ // Windows: %ProgramData%\subagent-mcp\slots
26
+ // macOS/Linux: /tmp/subagent-mcp/slots
27
+ {
28
+ "globalConcurrentSubagents": 20
29
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { consumeStreamChunk, flushStream, isTurnCompletedLine, retainLastN, } fr
18
18
  import { loadRoutingTable, buildCandidates, validatePresence, TASK_CATEGORIES, AUTO_HINT, SPLIT_HINT, } from "./routing.js";
19
19
  import { createDeadlockWindow } from "./deadlock.js";
20
20
  import { createRulesetGate, RULESET_HARD_FAIL_MSG, } from "./ruleset.js";
21
+ import { CONFIG_FILENAME, defaultConfigPath, globalCapMessage, readGlobalCap, releaseSlot, reserveSlot, } from "./concurrency.js";
21
22
  import * as orchestrationMarker from "./orchestration/marker.js";
22
23
  import * as modelMode from "./orchestration/model-mode.js";
23
24
  import { startLivenessHeartbeat } from "./orchestration/liveness.js";
@@ -139,11 +140,11 @@ reconcileInterval.unref();
139
140
  // test/mirror-fragments.test.mjs while ORCHESTRATION_INSTRUCTIONS below stays
140
141
  // compressed under MCP metadata limits:
141
142
  // READ-ESCALATION LADDER (the orchestrator's only read channels, in order): (1) subagent-mcp `poll_agent` TAIL; (2) if the tail is insufficient, dispatch ONE sub-agent to return a single summary of <=100 lines, trusted as-is (no separate verification step); (3) anything larger: the USER reads the document directly. No reads or writes occur outside these channels. An empty or stalled tail means the agent is ALIVE, not dead — do NOT busy-loop poll_agent; learn completion via `wait`. Large inter-agent data: the orchestrator assigns scratch-file paths (%TEMP% on Windows, /tmp on POSIX) in prompts; the producing sub-agent writes, the consuming sub-agent reads; the orchestrator NEVER reads those files.
142
- const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (full spec: docs/spec/dev-loop/orchestration-directive-architecture.md).\n\nPRECEDENCE. The latest <subagent-mcp state=\"...\"> hook tag and repo/system safety rules are co-supreme; genuine conflict => STOP and ask the user. Only the hook state changes ON/OFF.\n\nSOLE CHANNEL. Every sub-agent launch uses launch_agent; never harness Task/Agent or shell-spawned agents.\n\nORCHESTRATION ON. You are a delegate-ONLY orchestrator. Use only the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex), subagent-mcp, and the /workflows tool. No direct reads/writes; inline-by-right does not exist. Non-delegable atomic step: ask for a one-time exception, do only that step, then resume delegating.\n\nREAD LADDER. poll_agent tail -> one <=100-line summarizer sub-agent, trusted as-is -> else the USER reads it. Large handoffs use scratch-file paths; producer writes, consumer reads; orchestrator never reads those files. Empty/stalled tail means ALIVE; use wait.\n\nORCHESTRATION OFF. If total context footprint since last upgrade ask exceeds 200 lines, after that turn STOP and ask whether to switch ON; reset count only when you ask.\n\nDROPOUT WHILE ON: HALT and ask; stay halted until restored. SUB-AGENT EXEMPTION: a prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" skips this regime.\n\nMODEL SELECTION MODE. Default smart rejects provider/model/effort selectors; launch_agent auto-picks. user-approved-overrides lasts 30 minutes, expires lazily on launch_agent, and must be enabled only after explicit user authorization via AskUserQuestion/request-user-input.";
143
+ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (full spec: docs/spec/dev-loop/orchestration-directive-architecture.md).\n\nPRECEDENCE. The latest <subagent-mcp state=\"...\"> hook tag and repo/system safety rules are co-supreme; genuine conflict => STOP and ask the user. Only the hook state changes ON/OFF.\n\nSOLE CHANNEL. Every sub-agent launch uses launch_agent; never harness Task/Agent or shell-spawned agents.\n\nORCHESTRATION ON. You are a delegate-ONLY orchestrator. Use only the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex), subagent-mcp, and the /workflows tool. No direct reads/writes; inline-by-right does not exist. Non-delegable atomic step: ask for a one-time exception, do only that step, then resume delegating.\n\nREAD LADDER. poll_agent tail -> one <=100-line summarizer sub-agent, trusted as-is -> else the USER reads it. Large handoffs use scratch-file paths; producer writes, consumer reads; orchestrator never reads those files. Empty/stalled tail means ALIVE; use wait.\n\nORCHESTRATION OFF. If total context footprint since last upgrade ask exceeds 200 lines, after that turn STOP and ask whether to remain enabled; reset count only when you ask.\n\nDROPOUT WHILE ON: HALT and ask; stay halted until restored. SUB-AGENT EXEMPTION: a prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" skips this regime.\n\nMODEL SELECTION MODE. Default smart rejects provider/model/effort selectors; launch_agent auto-picks. user-approved-overrides lasts 30 minutes, expires lazily on launch_agent, and must be enabled only after explicit user authorization via AskUserQuestion/request-user-input.";
143
144
  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.";
144
145
  const server = new McpServer({
145
146
  name: "subagent-mcp",
146
- version: "2.9.0",
147
+ version: "2.10.0",
147
148
  description: "Launches always-interactive local Claude and Codex sub-agent sessions. Claude uses the Claude Agent SDK over the local Claude Code executable; Codex uses `codex app-server` over stdio. The server does not call Anthropic or OpenAI HTTP APIs directly.",
148
149
  }, {
149
150
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -359,6 +360,8 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
359
360
  }
360
361
  // Always clean up ultracode settings file on close
361
362
  cleanupUcSettings(agentState);
363
+ releaseSlot(agentState.slotPath ?? null);
364
+ agentState.slotPath = null;
362
365
  // Always record actual close time (unless already finalized)
363
366
  if (agentState.exitedAt === null)
364
367
  agentState.exitedAt = Date.now();
@@ -599,69 +602,94 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
599
602
  }
600
603
  // 6. Attempt loop: best→worst. Register on first successful driver start; silently
601
604
  // advance on launch-time failure. Sub-agent task outcome is NEVER a trigger.
605
+ const cap = readGlobalCap();
606
+ const reservationId = randomUUID();
607
+ const reservation = reserveSlot(reservationId, cap);
608
+ if (!reservation.ok) {
609
+ return errorResult(globalCapMessage(reservation.current, cap, defaultConfigPath()));
610
+ }
602
611
  const skipped = [];
603
- for (const candidate of candidates) {
604
- const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
605
- ? { applied: true, originalSelection: rulesetOriginalSelection }
606
- : undefined);
607
- if ("agentId" in outcome) {
608
- if (branch === "performance") {
609
- deadlockWindow.consume();
610
- }
611
- if (skipped.length > 0) {
612
+ let launched = false;
613
+ try {
614
+ for (const candidate of candidates) {
615
+ const outcome = await tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetApplied && rulesetOriginalSelection !== undefined
616
+ ? { applied: true, originalSelection: rulesetOriginalSelection }
617
+ : undefined);
618
+ if ("agentId" in outcome) {
619
+ if (branch === "performance") {
620
+ deadlockWindow.consume();
621
+ }
612
622
  const registeredAgent = agents.get(outcome.agentId);
613
623
  if (registeredAgent) {
614
- registeredAgent.failoverFrom = skipped.map((s) => ({
615
- provider: s.provider,
616
- model: s.model,
617
- effort: s.effort,
618
- failure_type: s.failure_type,
619
- }));
624
+ registeredAgent.slotPath = reservation.slotPath;
625
+ // The child can reach a terminal state DURING tryLaunchCandidate's
626
+ // awaits — before slotPath was set — so its close handler ran
627
+ // releaseSlot(undefined) (a no-op) and left the slot leaked. Detect
628
+ // that here and release now. slotPath is unique per reservation, so
629
+ // this never frees another agent's slot.
630
+ if (registeredAgent.exitCode !== null) {
631
+ releaseSlot(registeredAgent.slotPath);
632
+ registeredAgent.slotPath = null;
633
+ }
634
+ if (skipped.length > 0) {
635
+ registeredAgent.failoverFrom = skipped.map((s) => ({
636
+ provider: s.provider,
637
+ model: s.model,
638
+ effort: s.effort,
639
+ failure_type: s.failure_type,
640
+ }));
641
+ }
620
642
  }
643
+ launched = true;
644
+ return {
645
+ content: [
646
+ {
647
+ type: "text",
648
+ text: JSON.stringify({
649
+ agent_id: outcome.agentId,
650
+ status: "processing",
651
+ provider: candidate.provider,
652
+ model: candidate.model,
653
+ effort: candidate.effort,
654
+ task_category,
655
+ ...(rulesetApplied
656
+ ? {
657
+ ruleset_applied: true,
658
+ ruleset_original_selection: rulesetOriginalSelection,
659
+ }
660
+ : {}),
661
+ ...(skipped.length > 0
662
+ ? {
663
+ failover_occurred: true,
664
+ failover_from: skipped.map((s) => ({
665
+ provider: s.provider,
666
+ model: s.model,
667
+ effort: s.effort,
668
+ failure_type: s.failure_type,
669
+ })),
670
+ failover_note: buildFailoverNote(skipped, candidate),
671
+ }
672
+ : {}),
673
+ }),
674
+ },
675
+ ],
676
+ };
677
+ }
678
+ skipped.push({
679
+ model: candidate.model,
680
+ effort: candidate.effort,
681
+ provider: candidate.provider,
682
+ reason: outcome.reason,
683
+ failure_type: outcome.failure_type,
684
+ });
685
+ if (!pureAuto) {
686
+ break;
621
687
  }
622
- return {
623
- content: [
624
- {
625
- type: "text",
626
- text: JSON.stringify({
627
- agent_id: outcome.agentId,
628
- status: "processing",
629
- provider: candidate.provider,
630
- model: candidate.model,
631
- effort: candidate.effort,
632
- task_category,
633
- ...(rulesetApplied
634
- ? {
635
- ruleset_applied: true,
636
- ruleset_original_selection: rulesetOriginalSelection,
637
- }
638
- : {}),
639
- ...(skipped.length > 0
640
- ? {
641
- failover_occurred: true,
642
- failover_from: skipped.map((s) => ({
643
- provider: s.provider,
644
- model: s.model,
645
- effort: s.effort,
646
- failure_type: s.failure_type,
647
- })),
648
- failover_note: buildFailoverNote(skipped, candidate),
649
- }
650
- : {}),
651
- }),
652
- },
653
- ],
654
- };
655
688
  }
656
- skipped.push({
657
- model: candidate.model,
658
- effort: candidate.effort,
659
- provider: candidate.provider,
660
- reason: outcome.reason,
661
- failure_type: outcome.failure_type,
662
- });
663
- if (!pureAuto) {
664
- break;
689
+ }
690
+ finally {
691
+ if (!launched) {
692
+ releaseSlot(reservation.slotPath);
665
693
  }
666
694
  }
667
695
  // 7. All candidates failed. Override selector modes are single-attempt hard
@@ -790,6 +818,8 @@ server.tool("kill_agent", "Terminate a live agent/session (status `processing`,
790
818
  // POSIX, SIGKILL the process (close handler records the real exit code).
791
819
  agent.status = "stopped";
792
820
  agent.driver.kill();
821
+ releaseSlot(agent.slotPath ?? null);
822
+ agent.slotPath = null;
793
823
  if (isWindows && agent.process.pid) {
794
824
  spawn("taskkill", ["/pid", String(agent.process.pid), "/t", "/f"], {
795
825
  windowsHide: true,
@@ -1004,24 +1034,52 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1004
1034
  };
1005
1035
  });
1006
1036
  // Tool 7: orchestration-mode
1007
- 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 — toggling OFF does not lift that obligation. When OFF, run the per-turn upgrade check: a long-horizon task = any whose TOTAL context footprint (input read + output produced) exceeds 200 lines, measured CUMULATIVELY since your last upgrade ask; after EVERY user turn, if it qualifies, STOP and ask the user whether to switch ON (ask every qualifying turn; a decline does not latch; reset the count only when you actually ask). The FULL operating model + governance is carried in this server's MCP `instructions` (read once at initialize) — this is the operational summary only; do not act on the mode without that detail. WHAT: a per-project toggle for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as a delegate-ONLY orchestrator: delegate every step; inline-by-right does not exist; a non-delegable atomic step requires a one-time user-approved exception via the structured-question tool (state which + why). PERSISTENCE: 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).", {
1037
+ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF for THIS session only, omit = query current state. SOLE CHANNEL: the subagent MCP is the ONLY sanctioned channel for launching sub-agents whether this mode is ON or OFF — toggling OFF does not lift that obligation. When OFF, run the per-turn upgrade check: a long-horizon task = any whose TOTAL context footprint (input read + output produced) exceeds 200 lines, measured CUMULATIVELY since your last upgrade ask; after EVERY user turn, if it qualifies, STOP and ask the user whether to remain enabled (ask every qualifying turn; a decline does not latch; reset the count only when you actually ask). The FULL operating model + governance is carried in this server's MCP `instructions` (read once at initialize) — this is the operational summary only; do not act on the mode without that detail. WHAT: a default-ON orchestration mode for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as a delegate-ONLY orchestrator: delegate every step; inline-by-right does not exist; a non-delegable atomic step requires a one-time user-approved exception via the structured-question tool (state which + why). PERSISTENCE: orchestration is DEFAULT ON; a permitted disable applies to THIS session only, resumes ON next new session (or after the 2h backstop), and cannot be re-enabled mid-session. CARRYOVER: if ON was inherited from a PRIOR session (provenance = carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker, never per turn) — you MUST then notify the user it auto-activated and confirm whether to keep it ON. DISABLE: never on your own initiative; you MAY PROPOSE turning it OFF on task-fit mismatch, but only EXPLICIT user permission (AskUserQuestion on Claude, request-user-input on Codex) may set enabled:false. Per-turn injection fires only in CLI hosts that load the bundled hook; desktop hosts toggle the marker but inject nothing (documented degradation).", {
1008
1038
  enabled: z.boolean().optional(),
1009
1039
  }, async (params) => {
1010
1040
  const cwd = process.cwd();
1041
+ const key = orchestrationMarker.readCurrentSession(cwd);
1011
1042
  if (params.enabled === true) {
1012
- orchestrationMarker.enable(cwd);
1043
+ if (!orchestrationMarker.isActive(cwd, key)) {
1044
+ return errorResult("orchestration already disabled for this session, cannot re-enable mid-session; resumes ON automatically next new session (or after the 2h backstop).");
1045
+ }
1046
+ return {
1047
+ content: [
1048
+ {
1049
+ type: "text",
1050
+ text: JSON.stringify({
1051
+ orchestration_mode: "ON",
1052
+ message: "orchestration is ON by default.",
1053
+ }),
1054
+ },
1055
+ ],
1056
+ };
1013
1057
  }
1014
1058
  else if (params.enabled === false) {
1015
- orchestrationMarker.disable(cwd);
1059
+ if (key)
1060
+ orchestrationMarker.writeDisable(key);
1061
+ else
1062
+ orchestrationMarker.writeDisableCwd(cwd);
1063
+ return {
1064
+ content: [
1065
+ {
1066
+ type: "text",
1067
+ text: JSON.stringify({
1068
+ orchestration_mode: "disabled-this-session",
1069
+ message: "orchestration disabled for THIS session only; the next new session resumes ON automatically (or after the 2h backstop); no mid-session re-enable.",
1070
+ }),
1071
+ },
1072
+ ],
1073
+ };
1016
1074
  }
1017
1075
  // enabled === undefined -> query only; no marker mutation.
1076
+ const active = orchestrationMarker.isActive(cwd, key);
1018
1077
  return {
1019
1078
  content: [
1020
1079
  {
1021
1080
  type: "text",
1022
1081
  text: JSON.stringify({
1023
- orchestration_mode: orchestrationMarker.isActive(cwd),
1024
- marker_path: orchestrationMarker.markerPath(cwd),
1082
+ orchestration_mode: active ? "ON" : "disabled-this-session",
1025
1083
  }),
1026
1084
  },
1027
1085
  ],
@@ -1126,7 +1184,9 @@ if (isMain) {
1126
1184
  }
1127
1185
  const installRoot = join(npmRoot.stdout.toString().trim(), ...pkg.name.split("/"));
1128
1186
  const rulesetPath = join(installRoot, "dist", "advanced-ruleset.py");
1187
+ const cfgPath = join(installRoot, "dist", CONFIG_FILENAME);
1129
1188
  let previousRuleset = null;
1189
+ let previousCfg = null;
1130
1190
  if (existsSync(rulesetPath)) {
1131
1191
  previousRuleset = readFileSync(rulesetPath);
1132
1192
  const backupPath = join(tmpdir(), `advanced-ruleset.py.bak-update-${Date.now()}`);
@@ -1139,6 +1199,18 @@ if (isMain) {
1139
1199
  process.exit(1);
1140
1200
  }
1141
1201
  }
1202
+ if (existsSync(cfgPath)) {
1203
+ previousCfg = readFileSync(cfgPath);
1204
+ const backupPath = join(tmpdir(), `${CONFIG_FILENAME}.bak-update-${Date.now()}`);
1205
+ try {
1206
+ writeFileSync(backupPath, previousCfg);
1207
+ console.log(`backed up user ${CONFIG_FILENAME} to ${backupPath}`);
1208
+ }
1209
+ catch (e) {
1210
+ console.error(`update refused before install: failed to back up ${CONFIG_FILENAME}: ${e instanceof Error ? e.message : String(e)}`);
1211
+ process.exit(1);
1212
+ }
1213
+ }
1142
1214
  const r = spawnNpm(npmArgs, "inherit");
1143
1215
  if (r.error) {
1144
1216
  console.error(`update failed to start npm: ${r.error.message}`);
@@ -1161,6 +1233,21 @@ if (isMain) {
1161
1233
  process.exit(1);
1162
1234
  }
1163
1235
  }
1236
+ if (previousCfg !== null) {
1237
+ try {
1238
+ const freshCfg = existsSync(cfgPath)
1239
+ ? readFileSync(cfgPath)
1240
+ : null;
1241
+ if (freshCfg === null || !previousCfg.equals(freshCfg)) {
1242
+ writeFileSync(cfgPath, previousCfg);
1243
+ console.log(`restored user ${CONFIG_FILENAME} (package update never overwrites user edits)`);
1244
+ }
1245
+ }
1246
+ catch (e) {
1247
+ console.error(`update failed to restore ${CONFIG_FILENAME}: ${e instanceof Error ? e.message : String(e)}`);
1248
+ process.exit(1);
1249
+ }
1250
+ }
1164
1251
  console.log("Update complete. Restart your CLI sessions so the MCP server picks up the new build.");
1165
1252
  }
1166
1253
  process.exit(code);
@@ -1184,11 +1271,14 @@ if (isMain) {
1184
1271
  process.exit(1);
1185
1272
  }
1186
1273
  // ORCHESTRATION MODE PERSISTS across restarts/sessions: the server does NOT
1187
- // clear the marker on startup. DEFAULT OFF now means ABSENCE of a marker — a
1188
- // project never enabled stays OFF; a project explicitly enabled persists ON
1189
- // until disabled with explicit user permission. On a new session the bundled
1190
- // hook detects the carried-over marker and prompts the user to confirm.
1191
- // (orchestrationMarker.disable is still used by the tool's enabled:false.)
1274
+ // clear the marker on startup. DEFAULT ON now means ABSENCE of a disable
1275
+ // record a project stays ON with no marker write needed; OFF is only a
1276
+ // per-session disable record that holds while it is active, cleared with
1277
+ // explicit user permission. On a new session a carried-over legacy ON marker
1278
+ // (if any) triggers a one-time prompt asking whether to remain enabled; under
1279
+ // default-ON this rarely fires.
1280
+ // (the tool's enabled:false writes a disable record via writeDisable /
1281
+ // writeDisableCwd; it does not call disable().)
1192
1282
  startLivenessHeartbeat();
1193
1283
  const transport = new StdioServerTransport();
1194
1284
  await server.connect(transport);
package/dist/init.js CHANGED
@@ -29,7 +29,7 @@ export const INIT_BLOCK = [
29
29
  "",
30
30
  'NO-HOOK / UNKNOWN STATE: if no harness-hook injection bearing a <subagent-mcp state="..."> tag is present this session (e.g. Gemini, desktop apps, or any host that fires no hook), the state is UNKNOWN — represented by the ABSENCE of any tag, never by a tag value. Emit this UNKNOWN-STATE WARNING to the user: "subagent-mcp: no hook injection detected — orchestration state unknown; defaulting to ON". Briefly explain WHY: no hook injection detected — cannot verify orchestration state; defaulting to ON to prevent uncontrolled inline execution. Then DEFAULT TO ORCHESTRATION ON (fail-safe). One-time opt-out: if you are not currently running an orchestration workflow, you may explicitly opt out of ON for this session by saying so now; this opt-out does not persist and is not recorded. The sub-agent first-line exemption is the ONLY automatic suppressor of this default.',
31
31
  "",
32
- "DISABLE: never on your own initiative; you MAY propose OFF on task-fit mismatch via the structured-question tool, and only explicit user approval may call orchestration-mode enabled:false.",
32
+ "DISABLE: never on your own initiative; you MAY propose OFF on task-fit mismatch via the structured-question tool, and only explicit user approval may set enabled:false — per-session only; the next new session resumes ON; no mid-session re-enable.",
33
33
  "<!-- subagent-mcp:managed:end -->",
34
34
  ].join("\n");
35
35
  function detectEol(s) {
@@ -234,7 +234,9 @@ export function runHook(payload, env, adapter) {
234
234
  }
235
235
  const cwd = payload.cwd || process.cwd();
236
236
  const current = sessionKey(payload);
237
- if (!marker.isActive(cwd)) {
237
+ if (current)
238
+ marker.writeCurrentSession(cwd, current);
239
+ if (!marker.isActive(cwd, current)) {
238
240
  // OFF: no claim machinery — just the per-prompt reminder cadence.
239
241
  const r = reminder.advance(cwd, current);
240
242
  return cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted);
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  const markerDir = join(tmpdir(), "subagent-mcp");
6
+ export const ORCH_DISABLE_TTL_MS = 2 * 60 * 60 * 1000; // 2h GC backstop ONLY (independent of model-mode WINDOW_MS)
6
7
  /**
7
8
  * Shared per-project state dir for ALL hook state files (marker + reminder
8
9
  * counter). Exported so sibling state modules key off the SAME location — a
@@ -33,15 +34,27 @@ export function normalizeCwd(cwd) {
33
34
  }
34
35
  return p;
35
36
  }
36
- export function cwdHash(cwd) {
37
+ function hashKey(key) {
37
38
  return createHash("sha256")
38
- .update(normalizeCwd(cwd), "utf8")
39
+ .update(key, "utf8")
39
40
  .digest("hex")
40
41
  .slice(0, 16);
41
42
  }
43
+ export function cwdHash(cwd) {
44
+ return hashKey(normalizeCwd(cwd));
45
+ }
42
46
  export function markerPath(cwd) {
43
47
  return join(markerDir, "orch-" + cwdHash(cwd) + ".flag");
44
48
  }
49
+ export function disablePath(sessionKey) {
50
+ return join(stateDir, `orch-disable-${hashKey(sessionKey)}.json`);
51
+ }
52
+ function cwdDisablePath(cwd) {
53
+ return join(stateDir, `orch-disable-${cwdHash(cwd)}.json`);
54
+ }
55
+ export function sessionPointerPath(cwd) {
56
+ return join(stateDir, `orch-session-${cwdHash(cwd)}.json`);
57
+ }
45
58
  /**
46
59
  * Enable orchestration for cwd. ALWAYS overwrites — re-enabling re-baselines by
47
60
  * clearing owner_session/baseline_turn back to null so the next hook turn
@@ -62,44 +75,105 @@ export function enable(cwd) {
62
75
  carryover_ack: false,
63
76
  };
64
77
  writeFileSync(markerPath(cwd), JSON.stringify(state), { encoding: "utf8", mode: 0o600 });
78
+ try {
79
+ unlinkSync(cwdDisablePath(cwd));
80
+ }
81
+ catch (e) {
82
+ if (e?.code !== "ENOENT") {
83
+ // Fail-safe: enable still succeeds if stale disable cleanup fails.
84
+ }
85
+ }
65
86
  }
66
87
  catch {
67
88
  // Fail-safe: never throw to the caller.
68
89
  }
69
90
  }
70
91
  /**
71
- * Disable orchestration for cwd by removing the marker.
72
- *
73
- * No existsSync() guard: that only opens a TOCTOU window where a concurrent
74
- * clearForCwd/disable for the same cwd removes the file between the check and
75
- * the unlink. We just unlink and swallow ENOENT (already-gone is success).
76
- *
77
- * KNOWN LIMITATION: the marker is keyed by cwd, NOT by session. Two CLI
78
- * sessions in the same project share one marker, so their enable/disable
79
- * interleave and the last writer wins. Per-session isolation would require
80
- * keying the marker by cwd+session_id; not done here because LOCKED DECISION 1
81
- * keys the marker by working directory alone. (The hook tracks the owning
82
- * session via owner_session so a carried-over marker is re-claimed, but the
83
- * marker file itself is still shared per cwd.)
92
+ * Disable orchestration for cwd using cwd-keyed shared fallback state. The
93
+ * hook's session-keyed path uses writeDisable(sessionKey) instead.
84
94
  */
85
95
  export function disable(cwd) {
86
96
  try {
87
- unlinkSync(markerPath(cwd));
97
+ mkdirSync(markerDir, { recursive: true, mode: 0o700 });
98
+ writeFileSync(cwdDisablePath(cwd), JSON.stringify({ disabled_at: Date.now() }), {
99
+ encoding: "utf8",
100
+ mode: 0o600,
101
+ });
88
102
  }
89
- catch (e) {
90
- if (e?.code !== "ENOENT") {
91
- // Any non-ENOENT failure is still swallowed (fail-safe); ENOENT means the
92
- // marker was already gone, which is the desired end state anyway.
93
- }
103
+ catch {
104
+ // Fail-safe: never throw to the caller.
105
+ }
106
+ }
107
+ export function writeDisable(sessionKey) {
108
+ try {
109
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
110
+ writeFileSync(disablePath(sessionKey), JSON.stringify({ disabled_at: Date.now() }), {
111
+ encoding: "utf8",
112
+ mode: 0o600,
113
+ });
114
+ }
115
+ catch {
116
+ // Fail-safe: never throw to the caller.
94
117
  }
95
118
  }
96
- export function isActive(cwd) {
119
+ export function writeDisableCwd(cwd) {
97
120
  try {
98
- return existsSync(markerPath(cwd));
121
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
122
+ writeFileSync(cwdDisablePath(cwd), JSON.stringify({ disabled_at: Date.now() }), {
123
+ encoding: "utf8",
124
+ mode: 0o600,
125
+ });
99
126
  }
100
127
  catch {
128
+ // Fail-safe: never throw to the caller.
129
+ }
130
+ }
131
+ export function writeCurrentSession(cwd, sessionKey) {
132
+ try {
133
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
134
+ writeFileSync(sessionPointerPath(cwd), JSON.stringify({ session_key: sessionKey }), {
135
+ encoding: "utf8",
136
+ mode: 0o600,
137
+ });
138
+ }
139
+ catch {
140
+ // Fail-safe: never throw to the caller.
141
+ }
142
+ }
143
+ export function readCurrentSession(cwd) {
144
+ try {
145
+ const raw = readFileSync(sessionPointerPath(cwd), "utf8");
146
+ const parsed = JSON.parse(raw);
147
+ return typeof parsed.session_key === "string" ? parsed.session_key : undefined;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ function isDisableActive(path, now) {
154
+ if (!existsSync(path)) {
101
155
  return false;
102
156
  }
157
+ const raw = readFileSync(path, "utf8");
158
+ const parsed = JSON.parse(raw);
159
+ if (typeof parsed.disabled_at !== "number") {
160
+ return false;
161
+ }
162
+ if (now - parsed.disabled_at <= ORCH_DISABLE_TTL_MS) {
163
+ return true;
164
+ }
165
+ // Lazy GC side-effect: the disable has expired, so remove it.
166
+ unlinkSync(path);
167
+ return false;
168
+ }
169
+ export function isActive(cwd, sessionKey) {
170
+ try {
171
+ const path = sessionKey === undefined ? cwdDisablePath(cwd) : disablePath(sessionKey);
172
+ return !isDisableActive(path, Date.now());
173
+ }
174
+ catch {
175
+ return true;
176
+ }
103
177
  }
104
178
  export function readMarker(cwd) {
105
179
  try {
@@ -136,9 +210,8 @@ export function writeMarker(cwd, obj) {
136
210
  }
137
211
  }
138
212
  /**
139
- * Marker removal alias, identical to disable. RETAINED for callers that clear a
140
- * marker explicitly (e.g. the tool's enabled:false path). NOTE: the server no
141
- * longer calls this on startup — orchestration mode now PERSISTS across sessions.
213
+ * Cwd-keyed disable alias, identical to disable. RETAINED for callers that
214
+ * clear legacy marker state explicitly (e.g. the tool's enabled:false path).
142
215
  */
143
216
  export function clearForCwd(cwd) {
144
217
  disable(cwd);
package/dist/routing.js CHANGED
@@ -120,10 +120,10 @@ export function normalizeEffort(provider, model, effort) {
120
120
  * explicit (provider+model+effort all present): one candidate from the user's
121
121
  * triple; the table is NOT read (works with a null table).
122
122
  *
123
- * auto/provider/provider_model: read performance.<task_category> as the pairings
124
- * array directly (defensive .pairings unwrap), sort by rank asc, map each model
125
- * to a launch id, normalize effort, and drop non-launchable / unknown-effort
126
- * pairings. An empty result sets noCandidates:true.
123
+ * auto/provider/provider_model: read <branch>.<task_category> as the pairings
124
+ * array directly, sort by rank asc, map each model to a launch id, normalize
125
+ * effort, and drop non-launchable / unknown-effort pairings. An empty result
126
+ * sets noCandidates:true.
127
127
  */
128
128
  export function buildCandidates(table, taskCategory, overrides, branch = DEFAULT_BRANCH) {
129
129
  const { provider, model, effort } = overrides;
@@ -188,12 +188,7 @@ function readPairings(table, taskCategory, branch = DEFAULT_BRANCH) {
188
188
  const perf = table[branch];
189
189
  if (!perf || typeof perf !== "object")
190
190
  return [];
191
- let raw = perf[taskCategory];
192
- // Defensive: tolerate a {pairings:[...]} wrapper even though the contract
193
- // says the value IS the array directly.
194
- if (raw && !Array.isArray(raw) && typeof raw === "object" && "pairings" in raw) {
195
- raw = raw.pairings;
196
- }
191
+ const raw = perf[taskCategory];
197
192
  if (!Array.isArray(raw))
198
193
  return [];
199
194
  const entries = raw.filter((e) => !!e && typeof e === "object" && typeof e.model === "string");
@@ -251,7 +246,11 @@ export function validatePresence(p) {
251
246
  if (model && !provider) {
252
247
  return `Error: provider is required when model is given. You passed model=${model} without provider. Either also pass provider, or omit both.\n${AUTO_HINT}`;
253
248
  }
254
- // 5. explicit mode only: provider+model must satisfy the existing match rule.
249
+ // fallback_default is valid only for fully explicit launches.
250
+ if (task_category === "fallback_default" && !(provider && model && effort)) {
251
+ return `Error: fallback_default is a split hint sentinel, not a launchable routing-table category.\n${SPLIT_HINT}\n${AUTO_HINT}`;
252
+ }
253
+ // provider+model must satisfy the existing match rule.
255
254
  if (provider && model) {
256
255
  if (provider === "claude" && !["haiku", "sonnet", "opus", "opus-4-8"].includes(model)) {
257
256
  return `Error: Claude provider only supports haiku, sonnet, opus, or opus-4-8. Got: ${model}`;
package/dist/setup.js CHANGED
@@ -227,6 +227,7 @@ export function verifyInstall(root = INSTALL_ROOT) {
227
227
  const required = [
228
228
  "dist/index.js",
229
229
  "dist/advanced-ruleset.py",
230
+ "dist/global-concurrency.jsonc",
230
231
  "dist/hooks/orchestration-claude.js",
231
232
  "dist/hooks/orchestration-claude-pretool.js",
232
233
  "dist/hooks/orchestration-codex.js",
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions (no direct Anthropic/OpenAI API).",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "mcp-server",
9
+ "claude",
10
+ "claude-code",
11
+ "codex",
12
+ "orchestration",
13
+ "subagent",
14
+ "sub-agent",
15
+ "multi-agent",
16
+ "ai-agents",
17
+ "anthropic",
18
+ "openai"
19
+ ],
5
20
  "type": "module",
6
21
  "main": "dist/index.js",
7
22
  "bin": {
@@ -22,7 +37,7 @@
22
37
  "postinstall": "node scripts/postinstall.mjs",
23
38
  "prepare": "npm run build",
24
39
  "prepublishOnly": "npm test",
25
- "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/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-pretool.test.mjs && node test/orchestration-directives.test.mjs && node test/no-five-call.test.mjs && node test/check-worktree-subagent.test.mjs && node test/launch-agent-upsert.test.mjs && node test/claude-session-limit.test.mjs && node test/init-migration.test.mjs && node test/init-global.test.mjs && node test/mirror-fragments.test.mjs && node test/performance-tier-effort.test.mjs && node test/setup-repair.test.mjs && node test/setup-quoting.test.mjs && node test/setup-wire.test.mjs && node test/setup-cli-integration.test.mjs && node test/init.test.mjs && node test/model-selection-mode.test.mjs && node test/cli-args.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/mcp-compliance.test.mjs"
40
+ "test": "npm run check:versions && node test/effort.test.mjs && node test/drivers.test.mjs && node test/platform.test.mjs && node test/wait.test.mjs && node test/status.test.mjs && node test/output.test.mjs && node test/stream.test.mjs && node test/routing.test.mjs && node test/deadlock.test.mjs && node test/handler-validation.test.mjs && node test/index-handler.test.mjs && node test/ruleset.test.mjs && node test/ruleset-exec.test.mjs && node test/ruleset-handler.test.mjs && node test/failover.test.mjs && node test/global-concurrency-cap.test.mjs && node test/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-pretool.test.mjs && node test/orchestration-directives.test.mjs && node test/no-five-call.test.mjs && node test/check-worktree-subagent.test.mjs && node test/launch-agent-upsert.test.mjs && node test/claude-session-limit.test.mjs && node test/init-migration.test.mjs && node test/init-global.test.mjs && node test/mirror-fragments.test.mjs && node test/performance-tier-effort.test.mjs && node test/setup-repair.test.mjs && node test/setup-quoting.test.mjs && node test/setup-wire.test.mjs && node test/setup-cli-integration.test.mjs && node test/init.test.mjs && node test/model-selection-mode.test.mjs && node test/cli-args.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/mcp-compliance.test.mjs"
26
41
  },
27
42
  "author": "Lexi Blackburn",
28
43
  "license": "Apache-2.0",
@@ -47,7 +62,7 @@
47
62
  "node": ">=18"
48
63
  },
49
64
  "publishConfig": {
50
- "registry": "https://npm.pkg.github.com",
65
+ "registry": "https://registry.npmjs.org",
51
66
  "access": "public"
52
67
  }
53
68
  }