@heretyc/subagent-mcp 2.12.13 → 2.12.15

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,170 +1,170 @@
1
- # subagent-mcp
2
-
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)
7
-
8
- ## Core Premise
9
-
10
- subagent-mcp is an MCP stdio server that turns an AI coding assistant (Claude
11
- Code, Codex, Gemini CLI) into a manager of local Claude and Codex sub-agents on
12
- macOS, Linux, and Windows. It drives the locally authenticated `claude` and
13
- `codex` CLIs you already signed into. It does not make direct HTTP API calls and
14
- does not require API keys.
15
-
16
- The orchestrator monitors but does not read or write project files itself. Work
17
- is delegated to fresh sub-agents, so the orchestrator keeps summaries instead
18
- of raw file context. The main invariants are:
19
-
20
- - one machine-global, provider-agnostic concurrency cap (default 20, minimum 10)
21
- - fail-safe orchestration ON on hookless hosts
22
- - state authority only from harness-verified `<subagent-mcp state="...">` tags
23
- - sub-agents gated by default with permission ceiling `auto`
24
- - automatic model, provider, and effort routing per task category
25
-
26
- ## Install
27
-
28
- ### What You Need First
29
-
30
- - Node.js 18 or newer (`node --version`)
31
- - `claude` CLI, installed and signed in (`claude --version`)
32
- - `codex` CLI, installed and signed in (`codex --version`; optional if you only
33
- use Claude)
34
-
35
- Building from source needs extra developer tools. See
36
- [CONTRIBUTING.md](CONTRIBUTING.md).
37
-
38
- ### Install The Package
39
-
40
- ```bash
41
- npm install -g @heretyc/subagent-mcp
42
- ```
43
-
44
- This is the standard install. Organizations pinning the package through GitHub
45
- Packages should see [docs/registration.md](docs/registration.md).
46
-
47
- ### Wire It Into Your Assistant
48
-
49
- ```bash
50
- subagent-mcp setup
51
- ```
52
-
53
- Installing the package only ships the program. It does not connect anything on
54
- its own. `subagent-mcp setup` finds your Claude Code or Codex install and
55
- registers both the server and the per-turn orchestration hooks. Preview first
56
- with `subagent-mcp setup --dry-run`.
57
-
58
- ### Restart, Then Turn On The Invariant
59
-
60
- Restart your Claude Code or Codex session so it picks up the new tools. On
61
- Codex, run `/hooks` and trust the new hook. Then, recommended:
62
-
63
- ```bash
64
- subagent-mcp init --global
65
- ```
66
-
67
- This writes a managed "always delegate" rule block into your global assistant
68
- config once. For one project only, use
69
- `subagent-mcp init --root /path/to/project`. Full per-platform wiring (Gemini
70
- CLI, Claude Desktop, manual setup) is in
71
- [docs/registration.md](docs/registration.md).
72
-
73
- ## How To Operate It
74
-
75
- ### Orchestration Mode
76
-
77
- - **ON**: your assistant acts as a pure manager. It delegates every step to
78
- sub-agents. Best for big, long-running jobs.
79
- - **OFF**: your assistant works normally, with no delegation rules.
80
-
81
- Flip it with the `orchestration-mode` tool. Desktop apps can toggle the mode but
82
- do not receive per-turn hook reminders.
83
-
84
- ### Tools
85
-
86
- The server exposes `launch_agent`, `poll_agent`, `kill_agent`, `send_message`,
87
- `list_agents`, `wait`, `respond_permission`, `orchestration-mode`, and
88
- `model-selection-mode`. See [docs/tools.md](docs/tools.md) for the full
89
- parameter and return reference.
90
-
91
- You do not have to choose a model. Give `launch_agent` a prompt and a task
92
- category such as `coding`, `debugging`, or `security_review`; the server picks
93
- the provider, model, and effort.
94
-
95
- ### Concurrency
96
-
97
- There is one machine-wide limit on concurrent sub-agents. The default is 20.
98
- When the limit is reached, `launch_agent` is rejected immediately and does not
99
- queue. Change the value in `global-subagent-mcp-config.jsonc` in the install
100
- folder. The file is re-read on every launch.
101
-
102
- The config file was renamed from `global-concurrency.jsonc` in 2.12.5. The old
103
- name is still read, with a one-time deprecation notice, when the new file is
104
- absent.
105
-
106
- The same settings file includes `checkForUpdates` (default `true`). Disable it
107
- with `checkForUpdates: false` or `SUBAGENT_UPDATE_CHECK=0`.
108
-
109
- ## Configuration
110
-
111
- Machine-wide defaults live in `global-subagent-mcp-config.jsonc`, installed
112
- beside the compiled server and re-read on every `launch_agent`. It controls the
113
- global concurrency cap, update checks, permission ceiling, escalation behavior,
114
- strict read-parity logging, and Codex sandbox networking.
115
-
116
- User and repo permission files can only tighten or add scoped permissions on top
117
- of the global ceiling. See [README/configuration.md](README/configuration.md)
118
- for the full key table, precedence rules, and mode summary.
119
-
120
- ## Permissions
121
-
122
- Launched sub-agents run gated by default. Set `permissionsCeiling` in
123
- `global-subagent-mcp-config.jsonc`:
124
-
125
- | Mode | What a sub-agent can do |
126
- |---|---|
127
- | `auto` | Default. Safe reads auto-allow, dangerous actions auto-deny, everything else parks for your decision. |
128
- | `manual` | Same, but every non-denied action parks for a decision. |
129
- | `yolo` | No gating at all. |
130
-
131
- When a sub-agent's action parks, its status becomes `permission_requested` and
132
- it appears in `poll_agent`, `list_agents`, and `wait`. Answer it with:
133
-
134
- ```text
135
- respond_permission(agent_id="...", decision="allow" | "deny", reason="...")
136
- ```
137
-
138
- One-time only. Omit `request_id` to answer the oldest pending request.
139
- Unanswered requests auto-deny after 5 minutes. Full spec:
140
- [docs/spec/permissions.md](docs/spec/permissions.md).
141
-
142
- ## Basic Debugging
143
-
144
- - **An agent looks stuck.** A quiet agent is usually still alive. After about 10
145
- minutes with no output an agent is marked `stalled`. Prefer `wait` or another
146
- `poll_agent` over killing it.
147
- - **Cap reached.** Use `list_agents` to see what is running and `kill_agent` on
148
- work you no longer need. Raising `globalConcurrentSubagents` also works.
149
- - **Logs.** Agent output is available through `poll_agent`. Server diagnostics
150
- go to the host MCP server log on stderr.
151
-
152
- ## Documentation
153
-
154
- | Document | Contents |
155
- |---|---|
156
- | [docs/spec/arch-rationale.md](docs/spec/arch-rationale.md) | Design rationale |
157
- | [docs/registration.md](docs/registration.md) | Per-platform setup |
158
- | [docs/tools.md](docs/tools.md) | Tool reference |
159
- | [docs/usage.md](docs/usage.md) | Model and effort matrix |
160
- | [docs/SPEC.md](docs/SPEC.md) | Technical specification |
161
- | [README/configuration.md](README/configuration.md) | Configuration keys and precedence |
162
- | [docs/spec/permissions.md](docs/spec/permissions.md) | Permission system |
163
- | [docs/reference/status-lifecycle.md](docs/reference/status-lifecycle.md) | Agent status meanings |
164
- | [CONTRIBUTING.md](CONTRIBUTING.md) | Developer guide |
165
-
166
- ## License
167
-
168
- Apache-2.0. Copyright 2026 Lexi Blackburn.
169
-
170
- See [LICENSE](LICENSE).
1
+ # subagent-mcp
2
+
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)
7
+
8
+ ## Core Premise
9
+
10
+ subagent-mcp is an MCP stdio server that turns an AI coding assistant (Claude
11
+ Code, Codex, Gemini CLI) into a manager of local Claude and Codex sub-agents on
12
+ macOS, Linux, and Windows. It drives the locally authenticated `claude` and
13
+ `codex` CLIs you already signed into. It does not make direct HTTP API calls and
14
+ does not require API keys.
15
+
16
+ The orchestrator monitors but does not read or write project files itself. Work
17
+ is delegated to fresh sub-agents, so the orchestrator keeps summaries instead
18
+ of raw file context. The main invariants are:
19
+
20
+ - one machine-global, provider-agnostic concurrency cap (default 20, minimum 10)
21
+ - fail-safe orchestration ON on hookless hosts
22
+ - state authority only from harness-verified `<subagent-mcp state="...">` tags
23
+ - sub-agents gated by default with permission ceiling `auto`
24
+ - automatic model, provider, and effort routing per task category
25
+
26
+ ## Install
27
+
28
+ ### What You Need First
29
+
30
+ - Node.js 18 or newer (`node --version`)
31
+ - `claude` CLI, installed and signed in (`claude --version`)
32
+ - `codex` CLI, installed and signed in (`codex --version`; optional if you only
33
+ use Claude)
34
+
35
+ Building from source needs extra developer tools. See
36
+ [CONTRIBUTING.md](CONTRIBUTING.md).
37
+
38
+ ### Install The Package
39
+
40
+ ```bash
41
+ npm install -g @heretyc/subagent-mcp
42
+ ```
43
+
44
+ This is the standard install. Organizations pinning the package through GitHub
45
+ Packages should see [docs/registration.md](docs/registration.md).
46
+
47
+ ### Wire It Into Your Assistant
48
+
49
+ ```bash
50
+ subagent-mcp setup
51
+ ```
52
+
53
+ Installing the package only ships the program. It does not connect anything on
54
+ its own. `subagent-mcp setup` finds your Claude Code or Codex install and
55
+ registers both the server and the per-turn orchestration hooks. Preview first
56
+ with `subagent-mcp setup --dry-run`.
57
+
58
+ ### Restart, Then Turn On The Invariant
59
+
60
+ Restart your Claude Code or Codex session so it picks up the new tools. On
61
+ Codex, run `/hooks` and trust the new hook. Then, recommended:
62
+
63
+ ```bash
64
+ subagent-mcp init --global
65
+ ```
66
+
67
+ This writes a managed "always delegate" rule block into your global assistant
68
+ config once. For one project only, use
69
+ `subagent-mcp init --root /path/to/project`. Full per-platform wiring (Gemini
70
+ CLI, Claude Desktop, manual setup) is in
71
+ [docs/registration.md](docs/registration.md).
72
+
73
+ ## How To Operate It
74
+
75
+ ### Orchestration Mode
76
+
77
+ - **ON**: your assistant acts as a pure manager. It delegates every step to
78
+ sub-agents. Best for big, long-running jobs.
79
+ - **OFF**: your assistant works normally, with no delegation rules.
80
+
81
+ Flip it with the `orchestration-mode` tool. Desktop apps can toggle the mode but
82
+ do not receive per-turn hook reminders.
83
+
84
+ ### Tools
85
+
86
+ The server exposes `launch_agent`, `poll_agent`, `kill_agent`, `send_message`,
87
+ `list_agents`, `wait`, `respond_permission`, `orchestration-mode`, and
88
+ `model-selection-mode`. See [docs/tools.md](docs/tools.md) for the full
89
+ parameter and return reference.
90
+
91
+ You do not have to choose a model. Give `launch_agent` a prompt and a task
92
+ category such as `coding`, `debugging`, or `security_review`; the server picks
93
+ the provider, model, and effort.
94
+
95
+ ### Concurrency
96
+
97
+ There is one machine-wide limit on concurrent sub-agents. The default is 20.
98
+ When the limit is reached, `launch_agent` is rejected immediately and does not
99
+ queue. Change the value in `global-subagent-mcp-config.jsonc` in the install
100
+ folder. The file is re-read on every launch.
101
+
102
+ The config file was renamed from `global-concurrency.jsonc` in 2.12.5. The old
103
+ name is still read, with a one-time deprecation notice, when the new file is
104
+ absent.
105
+
106
+ The same settings file includes `checkForUpdates` (default `true`). Disable it
107
+ with `checkForUpdates: false` or `SUBAGENT_UPDATE_CHECK=0`.
108
+
109
+ ## Configuration
110
+
111
+ Machine-wide defaults live in `global-subagent-mcp-config.jsonc`, installed
112
+ beside the compiled server and re-read on every `launch_agent`. It controls the
113
+ global concurrency cap, update checks, permission ceiling, escalation behavior,
114
+ strict read-parity logging, and Codex sandbox networking.
115
+
116
+ User and repo permission files can only tighten or add scoped permissions on top
117
+ of the global ceiling. See [README/configuration.md](README/configuration.md)
118
+ for the full key table, precedence rules, and mode summary.
119
+
120
+ ## Permissions
121
+
122
+ Launched sub-agents run gated by default. Set `permissionsCeiling` in
123
+ `global-subagent-mcp-config.jsonc`:
124
+
125
+ | Mode | What a sub-agent can do |
126
+ |---|---|
127
+ | `auto` | Default. Safe reads auto-allow, dangerous actions auto-deny, everything else parks for your decision. |
128
+ | `manual` | Same, but every non-denied action parks for a decision. |
129
+ | `yolo` | No gating at all. |
130
+
131
+ When a sub-agent's action parks, its status becomes `permission_requested` and
132
+ it appears in `poll_agent`, `list_agents`, and `wait`. Answer it with:
133
+
134
+ ```text
135
+ respond_permission(agent_id="...", decision="allow" | "deny", reason="...")
136
+ ```
137
+
138
+ One-time only. Omit `request_id` to answer the oldest pending request.
139
+ Unanswered requests auto-deny after 5 minutes. Full spec:
140
+ [docs/spec/permissions.md](docs/spec/permissions.md).
141
+
142
+ ## Basic Debugging
143
+
144
+ - **An agent looks stuck.** A quiet agent is usually still alive. After about 10
145
+ minutes with no output an agent is marked `stalled`. Prefer `wait` or another
146
+ `poll_agent` over killing it.
147
+ - **Cap reached.** Use `list_agents` to see what is running and `kill_agent` on
148
+ work you no longer need. Raising `globalConcurrentSubagents` also works.
149
+ - **Logs.** Agent output is available through `poll_agent`. Server diagnostics
150
+ go to the host MCP server log on stderr.
151
+
152
+ ## Documentation
153
+
154
+ | Document | Contents |
155
+ |---|---|
156
+ | [docs/spec/arch-rationale.md](docs/spec/arch-rationale.md) | Design rationale |
157
+ | [docs/registration.md](docs/registration.md) | Per-platform setup |
158
+ | [docs/tools.md](docs/tools.md) | Tool reference |
159
+ | [docs/usage.md](docs/usage.md) | Model and effort matrix |
160
+ | [docs/SPEC.md](docs/SPEC.md) | Technical specification |
161
+ | [README/configuration.md](README/configuration.md) | Configuration keys and precedence |
162
+ | [docs/spec/permissions.md](docs/spec/permissions.md) | Permission system |
163
+ | [docs/reference/status-lifecycle.md](docs/reference/status-lifecycle.md) | Agent status meanings |
164
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Developer guide |
165
+
166
+ ## License
167
+
168
+ Apache-2.0. Copyright 2026 Lexi Blackburn.
169
+
170
+ See [LICENSE](LICENSE).
@@ -1,67 +1,67 @@
1
- #!/usr/bin/env python3
2
- """advanced-ruleset.py — final-authority model-routing override hook for subagent-mcp.
3
-
4
- (a) PERFORMANCE WARNING: this script runs synchronously inside EVERY launch_agent
5
- call. Slow rules slow every agent launch. Keep rules lean and low-latency —
6
- no network calls, no heavy imports at module top. This is YOUR responsibility;
7
- you have been warned.
8
-
9
- (b) OUTPUT CONTRACT (routing mode): print to stdout ONE JSON array — the modified
10
- candidate list (reorder / filter / replace allowed). Template:
11
- [
12
- {"provider": "claude", "model": "sonnet", "effort": "high", "rank": 1},
13
- {"provider": "codex", "model": "gpt-5.5", "effort": "xhigh", "rank": 2}
14
- ]
15
- Valid providers: claude, codex. Valid models: haiku, sonnet, opus, opus-4-8, fable (claude);
16
- gpt-5.5 (codex). Valid efforts: haiku -> "none" only; sonnet -> medium|high|xhigh|max;
17
- fable -> medium|high|xhigh|max; opus/opus-4-8 -> those plus ultracode; gpt-5.5 -> medium|high|xhigh.
18
- "rank" on output is ignored. An EMPTY array vetoes the launch. Anything else
19
- invalid fails the launch hard — the server validates strictly.
20
-
21
- (c) INPUT CONTRACT (routing mode, invoked as: <python> advanced-ruleset.py route):
22
- stdin receives one JSON object:
23
- { "candidates": [ {"provider","model","effort","rank"} ... ], # rank 1..N best->worst
24
- "context": { "task_category": str, "cwd": str,
25
- "selection_mode": "auto"|"provider"|"provider_model"|"explicit",
26
- "provider": str|None, "model": str|None, "effort": str|None } }
27
- OS environment variables are visible natively (os.environ).
28
-
29
- ENV-CHECK MODE (no arguments): prints {"ready": true|false, "load-rules": true|false}.
30
- Runs once per MCP server process. load-rules false => ruleset silently disabled
31
- for the rest of the process. Set LOAD_RULES = True below to activate.
32
- """
33
- import json
34
- import sys
35
-
36
- LOAD_RULES = False
37
-
38
- # --- Requirements stub (scaffold itself is stdlib-only) ----------------------
39
- # List third-party distributions your rules import, e.g.:
40
- # REQUIREMENTS = ["requests", "pyyaml"]
41
- # Install with: <python> -m pip install <name> ...
42
- REQUIREMENTS = []
43
-
44
- def missing_requirements():
45
- """pip-check helper: returns the REQUIREMENTS entries not importable here."""
46
- import importlib.util
47
- return [r for r in REQUIREMENTS
48
- if importlib.util.find_spec(r.replace("-", "_")) is None]
49
-
50
- def env_check():
51
- missing = missing_requirements()
52
- json.dump({"ready": not missing, "load-rules": bool(LOAD_RULES)}, sys.stdout)
53
-
54
- def apply_rules(candidates, context):
55
- """YOUR RULES HERE. Default: passthrough (returns the list unchanged)."""
56
- return candidates
57
-
58
- def route():
59
- payload = json.load(sys.stdin)
60
- out = apply_rules(payload.get("candidates", []), payload.get("context", {}))
61
- json.dump(out, sys.stdout)
62
-
63
- if __name__ == "__main__":
64
- if len(sys.argv) > 1 and sys.argv[1] == "route":
65
- route()
66
- else:
67
- env_check()
1
+ #!/usr/bin/env python3
2
+ """advanced-ruleset.py — final-authority model-routing override hook for subagent-mcp.
3
+
4
+ (a) PERFORMANCE WARNING: this script runs synchronously inside EVERY launch_agent
5
+ call. Slow rules slow every agent launch. Keep rules lean and low-latency —
6
+ no network calls, no heavy imports at module top. This is YOUR responsibility;
7
+ you have been warned.
8
+
9
+ (b) OUTPUT CONTRACT (routing mode): print to stdout ONE JSON array — the modified
10
+ candidate list (reorder / filter / replace allowed). Template:
11
+ [
12
+ {"provider": "claude", "model": "sonnet", "effort": "high", "rank": 1},
13
+ {"provider": "codex", "model": "gpt-5.5", "effort": "xhigh", "rank": 2}
14
+ ]
15
+ Valid providers: claude, codex. Valid models: haiku, sonnet, opus, opus-4-8, fable (claude);
16
+ gpt-5.5 (codex). Valid efforts: haiku -> "none" only; sonnet -> medium|high|xhigh|max;
17
+ fable -> medium|high|xhigh|max; opus/opus-4-8 -> those plus ultracode; gpt-5.5 -> medium|high|xhigh.
18
+ "rank" on output is ignored. An EMPTY array vetoes the launch. Anything else
19
+ invalid fails the launch hard — the server validates strictly.
20
+
21
+ (c) INPUT CONTRACT (routing mode, invoked as: <python> advanced-ruleset.py route):
22
+ stdin receives one JSON object:
23
+ { "candidates": [ {"provider","model","effort","rank"} ... ], # rank 1..N best->worst
24
+ "context": { "task_category": str, "cwd": str,
25
+ "selection_mode": "auto"|"provider"|"provider_model"|"explicit",
26
+ "provider": str|None, "model": str|None, "effort": str|None } }
27
+ OS environment variables are visible natively (os.environ).
28
+
29
+ ENV-CHECK MODE (no arguments): prints {"ready": true|false, "load-rules": true|false}.
30
+ Runs once per MCP server process. load-rules false => ruleset silently disabled
31
+ for the rest of the process. Set LOAD_RULES = True below to activate.
32
+ """
33
+ import json
34
+ import sys
35
+
36
+ LOAD_RULES = False
37
+
38
+ # --- Requirements stub (scaffold itself is stdlib-only) ----------------------
39
+ # List third-party distributions your rules import, e.g.:
40
+ # REQUIREMENTS = ["requests", "pyyaml"]
41
+ # Install with: <python> -m pip install <name> ...
42
+ REQUIREMENTS = []
43
+
44
+ def missing_requirements():
45
+ """pip-check helper: returns the REQUIREMENTS entries not importable here."""
46
+ import importlib.util
47
+ return [r for r in REQUIREMENTS
48
+ if importlib.util.find_spec(r.replace("-", "_")) is None]
49
+
50
+ def env_check():
51
+ missing = missing_requirements()
52
+ json.dump({"ready": not missing, "load-rules": bool(LOAD_RULES)}, sys.stdout)
53
+
54
+ def apply_rules(candidates, context):
55
+ """YOUR RULES HERE. Default: passthrough (returns the list unchanged)."""
56
+ return candidates
57
+
58
+ def route():
59
+ payload = json.load(sys.stdin)
60
+ out = apply_rules(payload.get("candidates", []), payload.get("context", {}))
61
+ json.dump(out, sys.stdout)
62
+
63
+ if __name__ == "__main__":
64
+ if len(sys.argv) > 1 and sys.argv[1] == "route":
65
+ route()
66
+ else:
67
+ env_check()
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir, platform, userInfo } from "node:os";
4
4
  import { dirname, join, parse as parsePath, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -26,7 +26,50 @@ export function clampCap(raw) {
26
26
  return v;
27
27
  }
28
28
  export function stripJsoncComments(text) {
29
- return text.replace(/^\uFEFF/, "").replace(/^\s*\/\/.*$/gm, "");
29
+ let out = "";
30
+ let inString = false;
31
+ let quote = null;
32
+ let escaped = false;
33
+ for (let i = text.startsWith("\uFEFF") ? 1 : 0; i < text.length; i++) {
34
+ const ch = text[i];
35
+ const next = text[i + 1];
36
+ if (inString) {
37
+ out += ch;
38
+ if (escaped) {
39
+ escaped = false;
40
+ }
41
+ else if (ch === "\\") {
42
+ escaped = true;
43
+ }
44
+ else if (ch === quote) {
45
+ inString = false;
46
+ quote = null;
47
+ }
48
+ continue;
49
+ }
50
+ if (ch === "\"" || ch === "'") {
51
+ inString = true;
52
+ quote = ch;
53
+ out += ch;
54
+ continue;
55
+ }
56
+ if (ch === "/" && next === "/") {
57
+ while (i < text.length && text[i] !== "\n" && text[i] !== "\r")
58
+ i++;
59
+ i--;
60
+ continue;
61
+ }
62
+ if (ch === "/" && next === "*") {
63
+ i += 2;
64
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
65
+ i++;
66
+ if (i < text.length)
67
+ i++;
68
+ continue;
69
+ }
70
+ out += ch;
71
+ }
72
+ return out;
30
73
  }
31
74
  function parseJsonObject(text) {
32
75
  const parsed = JSON.parse(stripJsoncComments(text));
@@ -486,7 +529,20 @@ function repoConfigDigest(cwd) {
486
529
  h.update(file).update("\0").update(readFileSync(file));
487
530
  return h.digest("hex");
488
531
  }
532
+ const FIRST_REPO_DIGEST_LIMIT = 100;
533
+ // ponytail: process-local only; restart persistence is unnecessary for first-seen repo digests.
489
534
  const firstRepoDigests = new Map();
535
+ function rememberFirstRepoDigest(cwd, digest) {
536
+ if (firstRepoDigests.has(cwd)) {
537
+ firstRepoDigests.delete(cwd);
538
+ }
539
+ else if (firstRepoDigests.size >= FIRST_REPO_DIGEST_LIMIT) {
540
+ const oldest = firstRepoDigests.keys().next().value;
541
+ if (oldest !== undefined)
542
+ firstRepoDigests.delete(oldest);
543
+ }
544
+ firstRepoDigests.set(cwd, digest);
545
+ }
490
546
  export function readMergedPermissionConfig(cwd, path = defaultConfigPath()) {
491
547
  const global = readGlobalConfig(path);
492
548
  noteLegacyConfigIfUsed(path);
@@ -537,7 +593,7 @@ export function readMergedPermissionConfig(cwd, path = defaultConfigPath()) {
537
593
  const first = firstRepoDigests.get(cwd);
538
594
  const repoConfigChangedSinceFirstSeen = first !== undefined && first !== digest;
539
595
  if (first === undefined)
540
- firstRepoDigests.set(cwd, digest);
596
+ rememberFirstRepoDigest(cwd, digest);
541
597
  const selfProtectionDeny = configSelfProtectionDenyRules(path);
542
598
  const protectedDirs = new Set([resolve(path), resolve(legacyConfigPath(path))]);
543
599
  merged.deny.push(...selfProtectionDeny);
@@ -591,6 +647,74 @@ export function countSlots(dir = slotDir()) {
591
647
  return 0;
592
648
  }
593
649
  }
650
+ function slotPathForIndex(dir, index) {
651
+ return join(dir, `slot-${index}.json`);
652
+ }
653
+ function slotMetadataJson(agentId) {
654
+ const now = Date.now();
655
+ return JSON.stringify({
656
+ schema_version: 1,
657
+ agent_id: agentId,
658
+ server_pid: process.pid,
659
+ child_pid: null,
660
+ cwd: process.cwd(),
661
+ started_at: new Date(now).toISOString(),
662
+ started_at_ms: now,
663
+ last_activity_ms: now,
664
+ status: null,
665
+ });
666
+ }
667
+ function claimSlotPath(dir, agentId, max) {
668
+ let existing;
669
+ try {
670
+ existing = readdirSync(dir).filter((f) => f.startsWith("slot-"));
671
+ }
672
+ catch {
673
+ existing = [];
674
+ }
675
+ if (existing.length >= max)
676
+ return null;
677
+ const occupied = new Set(existing);
678
+ const candidateCount = max - existing.length;
679
+ const candidates = [];
680
+ for (let i = 0; i < max && candidates.length < candidateCount; i++) {
681
+ const name = `slot-${i}.json`;
682
+ if (!occupied.has(name))
683
+ candidates.push(slotPathForIndex(dir, i));
684
+ }
685
+ for (const path of candidates) {
686
+ let fd = null;
687
+ try {
688
+ fd = openSync(path, "wx", 0o600);
689
+ writeFileSync(fd, slotMetadataJson(agentId));
690
+ return path;
691
+ }
692
+ catch (e) {
693
+ if (fd !== null) {
694
+ try {
695
+ closeSync(fd);
696
+ }
697
+ catch { }
698
+ fd = null;
699
+ try {
700
+ unlinkSync(path);
701
+ }
702
+ catch { }
703
+ }
704
+ if (e.code !== "EEXIST")
705
+ throw e;
706
+ }
707
+ finally {
708
+ if (fd !== null) {
709
+ try {
710
+ closeSync(fd);
711
+ }
712
+ catch { }
713
+ }
714
+ }
715
+ }
716
+ return null;
717
+ }
594
718
  export function scheduleForceKill(ms, kill) {
595
719
  const timer = setTimeout(kill, ms);
596
720
  timer.unref();
@@ -608,21 +732,11 @@ export function reserveSlot(agentId, max, dir = slotDir(), cullDeps) {
608
732
  mkdirSync(dir, { recursive: true, mode: 0o700 });
609
733
  }
610
734
  cullStaleSlots(dir, cullDeps);
611
- const slotPath = slotPathForAgent(dir, agentId);
612
- const before = countSlots(dir);
613
- if (before >= max) {
614
- return { ok: false, current: before, max };
615
- }
616
- // ponytail: count->write->recount narrows the TOCTOU; a cross-process lock would close it fully.
617
- writeSlotMetadata(slotPath, { agent_id: agentId });
618
- const after = countSlots(dir);
619
- if (after > max) {
620
- try {
621
- unlinkSync(slotPath);
622
- }
623
- catch { }
735
+ // ponytail: numbered O_EXCL slot claims make admission atomic without queues or a process-wide lock.
736
+ const slotPath = claimSlotPath(dir, agentId, max);
737
+ if (!slotPath)
624
738
  return { ok: false, current: countSlots(dir), max };
625
- }
739
+ const after = countSlots(dir);
626
740
  return { ok: true, slotPath, current: after, max };
627
741
  }
628
742
  catch (e) {