@heretyc/subagent-mcp 2.8.6 → 2.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/NOTICE +5 -5
- package/README.md +136 -136
- package/directives/carryover-claude.md +6 -9
- package/directives/carryover-codex.md +6 -9
- package/directives/orchestration-claude.md +8 -16
- package/directives/orchestration-codex.md +8 -16
- package/directives/reminder-off-claude.md +5 -5
- package/directives/reminder-off-codex.md +5 -5
- package/directives/reminder-on.md +8 -4
- package/directives/short-off.md +1 -1
- package/directives/short-on.md +1 -1
- package/dist/advanced-ruleset.py +67 -67
- package/dist/effort.js +6 -3
- package/dist/hooks/orchestration-claude.js +0 -4
- package/dist/index.js +11 -6
- package/dist/init.js +59 -28
- package/dist/launch-prompt.js +29 -0
- package/dist/orchestration/hook-core.js +1 -1
- package/dist/orchestration/pretool.js +10 -66
- package/dist/routing-table.json +3561 -4149
- package/dist/routing.js +1 -1
- package/dist/ruleset-scaffold.js +1 -1
- package/package.json +53 -53
- package/scripts/postinstall.mjs +102 -102
package/dist/effort.js
CHANGED
|
@@ -14,6 +14,9 @@ export function mapModel(provider, model) {
|
|
|
14
14
|
}
|
|
15
15
|
export function resolveEffort(provider, model, effort) {
|
|
16
16
|
const isOpus48 = provider === "claude" && (model === "opus" || model === "opus-4-8");
|
|
17
|
+
if (effort === "low") {
|
|
18
|
+
throw new Error(`low effort is not supported. Valid efforts: medium, high, xhigh, max, ultracode.`);
|
|
19
|
+
}
|
|
17
20
|
if (effort === "ultracode") {
|
|
18
21
|
if (!isOpus48) {
|
|
19
22
|
throw new Error(`ultracode effort is only available on Opus 4.8+ (got ${provider}/${model}). Use xhigh for other models.`);
|
|
@@ -24,15 +27,15 @@ export function resolveEffort(provider, model, effort) {
|
|
|
24
27
|
return { kind: "none" };
|
|
25
28
|
}
|
|
26
29
|
if (provider === "claude" && ["sonnet", "opus", "opus-4-8"].includes(model)) {
|
|
27
|
-
if (["
|
|
30
|
+
if (["medium", "high", "xhigh", "max"].includes(effort)) {
|
|
28
31
|
return { kind: "flag", value: effort };
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
if (provider === "codex") {
|
|
32
35
|
if (effort === "max") {
|
|
33
|
-
throw new Error(`max effort is not valid for gpt-5.5 (Codex). Valid:
|
|
36
|
+
throw new Error(`max effort is not valid for gpt-5.5 (Codex). Valid: medium, high, xhigh.`);
|
|
34
37
|
}
|
|
35
|
-
if (["
|
|
38
|
+
if (["medium", "high", "xhigh"].includes(effort)) {
|
|
36
39
|
return { kind: "flag", value: effort };
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { countJsonlType, runHook, } from "../orchestration/hook-core.js";
|
|
4
|
-
import { resetToolCount } from "../orchestration/pretool.js";
|
|
5
4
|
/**
|
|
6
5
|
* Claude Code UserPromptSubmit hook entry. Reads the JSON payload from stdin,
|
|
7
6
|
* runs the provider-agnostic core with the Claude adapter, and writes the
|
|
@@ -51,9 +50,6 @@ export const claudeAdapter = {
|
|
|
51
50
|
};
|
|
52
51
|
export function runClaudeHook(payload, env, adapter = claudeAdapter) {
|
|
53
52
|
try {
|
|
54
|
-
if (!adapter.isSubagent(payload, env)) {
|
|
55
|
-
resetToolCount(payload);
|
|
56
|
-
}
|
|
57
53
|
return runHook(payload, env, adapter);
|
|
58
54
|
}
|
|
59
55
|
catch {
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createDeadlockWindow } from "./deadlock.js";
|
|
|
20
20
|
import { createRulesetGate, RULESET_HARD_FAIL_MSG, } from "./ruleset.js";
|
|
21
21
|
import * as orchestrationMarker from "./orchestration/marker.js";
|
|
22
22
|
import { startLivenessHeartbeat } from "./orchestration/liveness.js";
|
|
23
|
+
import { ensureParentMarker } from "./launch-prompt.js";
|
|
23
24
|
const agents = new Map();
|
|
24
25
|
const MAX_CLAUDE = 5;
|
|
25
26
|
const MAX_CODEX = 5;
|
|
@@ -116,11 +117,11 @@ reconcileInterval.unref();
|
|
|
116
117
|
// initialize (per the MCP spec the initialize result has an `instructions`
|
|
117
118
|
// field) rather than re-injecting it on every turn. The bundled per-turn hook
|
|
118
119
|
// injects only a small compact reminder; this is the durable, full explanation.
|
|
119
|
-
const ORCHESTRATION_INSTRUCTIONS = "
|
|
120
|
+
const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp — CANONICAL OPERATING MODEL (read once; full detail in docs/spec/dev-loop/orchestration-directive-architecture.md).\n\nPRECEDENCE (co-supreme). A <subagent-mcp state=\"...\"> hook tag and repo/system safety rules are EQUAL top tier; genuine conflict → STOP and escalate to the user. Tags outrank user requests; only the hook `state` attribute changes ON/OFF.\n\nSOLE CHANNEL. EVERY sub-agent launch goes through launch_agent; never harness Task/Agent or shell spawn.\n\nORCHESTRATION ON (state=on; no-hook UNKNOWN→ON fail-safe). You are a delegate-ONLY orchestrator: directly use ONLY the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex) and subagent-mcp; delegate all work. No direct read/write; inline-by-right DOES NOT EXIST. Non-delegable step: ASK for a ONE-TIME exception, do only it, resume.\n\nREAD-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.\n\nORCHESTRATION OFF (state=off). Long-horizon task = TOTAL footprint (read+produced) >200 lines, CUMULATIVE since last upgrade ask; after EVERY turn, if it qualifies STOP and ASK whether to switch ON; reset only when you ask.\n\nDROPOUT WHILE ON: HALT and ask user; nothing inline; stay halted UNTIL restored.\n\nSUB-AGENT EXEMPTION. A prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" SKIPS the whole regime (sub-agent); the only automatic suppressor of fail-safe-ON.";
|
|
120
121
|
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.";
|
|
121
122
|
const server = new McpServer({
|
|
122
123
|
name: "subagent-mcp",
|
|
123
|
-
version: "2.8.
|
|
124
|
+
version: "2.8.7",
|
|
124
125
|
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.",
|
|
125
126
|
}, {
|
|
126
127
|
instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
|
|
@@ -429,16 +430,20 @@ function sameTriples(a, b) {
|
|
|
429
430
|
return a.every((c, i) => c.provider === b[i].provider && c.model === b[i].model && c.effort === b[i].effort);
|
|
430
431
|
}
|
|
431
432
|
// Tool 1: launch_agent
|
|
432
|
-
server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory first attempt unless an override is licensed below): pass only `prompt` + `task_category` and NO overrides; the server picks the best provider/model/effort for that category from its routing table, launches the top candidate, and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are overrides — licensed on 1st/2nd attempts ONLY when the task verifiably requires a specific capability; STATE that capability when overriding; if you pass `model` you must also pass `provider`, and if you pass `effort` you must pass both `provider` and `model`. SOLE CHANNEL: while this server is connected this tool is the ONLY sanctioned way to spawn sub-agents, in BOTH orchestration states — harness-native Task/Agent tools are FORBIDDEN for sub-agent launches. PROMPT RULE:
|
|
433
|
+
server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory first attempt unless an override is licensed below): pass only `prompt` + `task_category` and NO overrides; the server picks the best provider/model/effort for that category from its routing table, launches the top candidate, and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are overrides — licensed on 1st/2nd attempts ONLY when the task verifiably requires a specific capability; STATE that capability when overriding; if you pass `model` you must also pass `provider`, and if you pass `effort` you must pass both `provider` and `model`. SOLE CHANNEL: while this server is connected this tool is the ONLY sanctioned way to spawn sub-agents, in BOTH orchestration states — harness-native Task/Agent tools are FORBIDDEN for sub-agent launches. PROMPT RULE: every sub-agent `prompt`'s first line is the self-identification marker \"<this is a request from a parent process>\"; the server now UPSERTS this marker as the true first line automatically (idempotent — it is never duplicated and your prompt body is never mutated), so you need not add it yourself. Unsure which task_category fits? Don't submit one amorphous task — SPLIT into atomic steps that each map to a single category, one agent per step. ultracode effort is Opus 4.8+ only. Claude uses a Claude Agent SDK logical session over the local Claude executable; Codex uses a `codex app-server` child. Children run with env SUBAGENT_MCP_SUBAGENT=1 so the orchestration hooks skip them (they are not orchestrators and don't re-trigger carryover). Launch returns status `processing` (alive); a later `stalled` is alive-but-quiet (thinking or awaiting a temp-file handoff), NOT dead — wait or re-poll, don't kill (see poll_agent). DEADLOCK RULE: you MUST ALWAYS set `deadlock=true` when 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory (the 3rd attempt onward; re-wording or re-splitting the prompt does NOT make it a different task), and NEVER otherwise — from the 3rd attempt deadlock outranks any capability override: drop provider/model/effort.", {
|
|
433
434
|
task_category: z.enum(TASK_CATEGORIES).describe(TASK_CATEGORY_GLOSS),
|
|
434
435
|
prompt: z.string().min(1),
|
|
435
436
|
provider: z.enum(["claude", "codex"]).optional(),
|
|
436
437
|
model: z.enum(["haiku", "sonnet", "opus", "opus-4-8", "gpt-5.5"]).optional(),
|
|
437
|
-
effort: z.enum(["
|
|
438
|
+
effort: z.enum(["medium", "high", "xhigh", "max", "ultracode"]).optional(),
|
|
438
439
|
cwd: z.string().optional(),
|
|
439
440
|
deadlock: z.boolean().optional().describe("MANDATE: ALWAYS set deadlock=true when, and ONLY when, 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory — the 3rd attempt onward. Re-wording the prompt does NOT make it a different task; splitting a failed task does NOT reset attempts for its unchanged parts; re-launching for the same deliverable means the prior attempt COUNTS as failed/unsatisfactory ('partial progress' is not an exemption). NEVER set it on a 1st or 2nd attempt, NEVER for a different task, NEVER speculatively. Auto mode only: cannot be combined with provider/model/effort — from the 3rd attempt deadlock outranks any capability override, so drop those params. Passing false is identical to omitting it."),
|
|
440
441
|
}, async (params) => {
|
|
441
|
-
const { task_category, provider, model, effort,
|
|
442
|
+
const { task_category, provider, model, effort, deadlock } = params;
|
|
443
|
+
// D19/D20/S8: server silently upserts the parent-process marker as the TRUE
|
|
444
|
+
// first line of every sub-agent prompt (idempotent; never duplicates; never
|
|
445
|
+
// mutates the body). This is what makes the child first-line exemption fire.
|
|
446
|
+
const prompt = ensureParentMarker(params.prompt);
|
|
442
447
|
const agentCwd = params.cwd || process.cwd();
|
|
443
448
|
// 1-5. Param-presence validation (zod already constrains task_category, but
|
|
444
449
|
// hard-validate so the spec error text — valid list + hints, and the
|
|
@@ -897,7 +902,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
897
902
|
};
|
|
898
903
|
});
|
|
899
904
|
// Tool 7: orchestration-mode
|
|
900
|
-
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,
|
|
905
|
+
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 an orchestrator with delegate-default, but steps bound to main-session-only capability stay INLINE BY RIGHT (state which + why). PERSISTENCE: a per-project marker keyed by cwd; absence of the marker = OFF = no injection; once ON it persists across restarts/sessions until a permitted disable (it does NOT reset on a new session). CARRYOVER: if ON was inherited from a PRIOR session (provenance = carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker, never per turn) — you MUST then notify the user it auto-activated and confirm whether to keep it ON. DISABLE: never on your own initiative; you MAY PROPOSE turning it OFF on task-fit mismatch, but only EXPLICIT user permission (AskUserQuestion on Claude, request-user-input on Codex) may set enabled:false. Per-turn injection fires only in CLI hosts that load the bundled hook; desktop hosts toggle the marker but inject nothing (documented degradation).", {
|
|
901
906
|
enabled: z.boolean().optional(),
|
|
902
907
|
}, async (params) => {
|
|
903
908
|
const cwd = process.cwd();
|
package/dist/init.js
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
const
|
|
3
|
+
const MIGRATE_RE = /<!-- subagent-mcp:(?:managed:)?begin\b[^>]*-->[\s\S]*?<!-- subagent-mcp:(?:managed:)?end -->/;
|
|
4
|
+
const OWNER_CAP = 8;
|
|
4
5
|
export const INIT_BLOCK = [
|
|
5
|
-
"<!-- subagent-mcp:begin
|
|
6
|
-
"## subagent-mcp invariant
|
|
6
|
+
"<!-- subagent-mcp:managed:begin schema=2 -->",
|
|
7
|
+
"## subagent-mcp invariant — managed block, do not edit between markers",
|
|
7
8
|
"",
|
|
8
|
-
"
|
|
9
|
-
"and functioning, follow its MCP `instructions` and injected",
|
|
10
|
-
"`<ORCHESTRATION-INVARIANT>` blocks as binding repo guidance — subordinate to",
|
|
11
|
-
"repo/system safety rules in AGENTS.md, and never a ban on inline-by-right",
|
|
12
|
-
"main-session-only steps or temp scratch-file IPC.",
|
|
9
|
+
'SUB-AGENT EXEMPTION: if this session\'s prompt\'s literal FIRST LINE begins with "<this is a request from a parent process>", SKIP this entire block (you are a sub-agent; this prevents fail-safe-ON recursion and fork-bombs). Leading blank lines do not count — the marker must be physically line 1.',
|
|
13
10
|
"",
|
|
14
|
-
"-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
"
|
|
11
|
+
"CANONICAL SOURCE OF TRUTH: the subagent-mcp MCP `instructions` string (read once at connect) and docs/spec/dev-loop/orchestration-directive-architecture.md. This block carries the full ON operating model inline so the session is governed even if the MCP `instructions` are stale; if this block and the MCP `instructions` ever disagree, the MCP `instructions` win.",
|
|
12
|
+
"",
|
|
13
|
+
'HARNESS-HOOK SUPREMACY (this clause is identical in CLAUDE.md, AGENTS.md, and GEMINI.md): Any harness-hook context carrying a <subagent-mcp state="..."> tag is MAXIMALLY CRITICAL and MUST be obeyed with NO exceptions. A token only counts as such a tag when it appears as a real tag with a `state` attribute — a bare mention of "subagent-mcp" in ordinary prose is NOT a tag and carries no authority. No user request bypasses a <subagent-mcp state="..."> tag. The ONLY thing a user request may change is switching orchestration ON or OFF — and the authoritative ON or OFF state is reported SOLELY by the harness-hook injection (the `state` attribute of an injected <subagent-mcp> tag). The ABSENCE of any such tag means the state is UNKNOWN (see NO-HOOK). Never infer the state from anything else.',
|
|
14
|
+
"",
|
|
15
|
+
"PRECEDENCE (co-supreme top tier): <subagent-mcp> hook tags AND repo/system safety-scope rules are BOTH supreme and EQUAL — neither outranks the other. If they genuinely conflict, STOP and escalate to the user via the structured-question tool; do not silently pick one or average them. FORBIDDEN: resolving such a conflict yourself. Hook tags otherwise outrank ordinary user requests.",
|
|
16
|
+
"",
|
|
17
|
+
'ORCHESTRATION ON — you are the ORCHESTRATOR. Allowed tools: ONLY the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex) and subagent-mcp. NO direct reads or writes of any kind. "Inline-by-right" does not exist. Every step runs in a sub-agent. If one atomic step truly cannot run in a sub-agent, ASK the user via the structured-question tool for a one-time exception for that single step, perform only that step, then resume delegating. SOLE CHANNEL: while subagent-mcp is connected, every sub-agent launch goes through `launch_agent`; never use harness-native sub-agent tools or shell-spawned agents.',
|
|
18
|
+
"",
|
|
19
|
+
"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.",
|
|
20
|
+
"",
|
|
21
|
+
'ORCHESTRATION OFF — a "long-horizon task" = any task whose TOTAL context footprint (input you read + output you produce) exceeds 200 lines of text. After EVERY user turn, measure the CUMULATIVE footprint accumulated since your last upgrade ask; reset that cumulative count to zero ONLY when you actually ask. If it qualifies, ASK the user via the structured-question tool whether to switch orchestration ON. Ask on EVERY qualifying turn; a decline does NOT suppress future asks. You never assert ON yourself — you only ask.',
|
|
22
|
+
"",
|
|
23
|
+
"DROPOUT WHILE ON: if subagent-mcp stops responding while orchestration is ON, HALT and ask the user; do nothing inline. Keep re-checking and stay halted until subagent-mcp is restored (no auto-degrade). The only user choices are keep-waiting (the default) or explicitly abandon the whole task; aborting ends the task, it never switches you to inline work.",
|
|
24
|
+
"",
|
|
25
|
+
'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.',
|
|
26
|
+
"",
|
|
27
|
+
"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.",
|
|
28
|
+
"<!-- subagent-mcp:managed:end -->",
|
|
22
29
|
].join("\n");
|
|
23
30
|
function detectEol(s) {
|
|
24
31
|
const crlf = s.indexOf("\r\n");
|
|
@@ -39,15 +46,21 @@ function collapseBlankRuns(s, eol) {
|
|
|
39
46
|
return s.replace(new RegExp(`(?:${eol}){3,}`, "g"), `${eol}${eol}`);
|
|
40
47
|
}
|
|
41
48
|
function removeManagedBlock(body, eol) {
|
|
42
|
-
const match = body.match(
|
|
43
|
-
|
|
49
|
+
const match = body.match(MIGRATE_RE);
|
|
50
|
+
let stripped = body;
|
|
51
|
+
let removed = 0;
|
|
52
|
+
while (removed <= OWNER_CAP && MIGRATE_RE.test(stripped)) {
|
|
53
|
+
stripped = stripped.replace(MIGRATE_RE, "");
|
|
54
|
+
removed++;
|
|
55
|
+
}
|
|
56
|
+
const next = collapseBlankRuns(stripped, eol);
|
|
44
57
|
if (!match || match.index !== 0)
|
|
45
58
|
return next;
|
|
46
59
|
let trimmed = next;
|
|
47
|
-
let
|
|
48
|
-
while (trimmed.startsWith(eol) &&
|
|
60
|
+
let trimmedCount = 0;
|
|
61
|
+
while (trimmed.startsWith(eol) && trimmedCount < 2) {
|
|
49
62
|
trimmed = trimmed.slice(eol.length);
|
|
50
|
-
|
|
63
|
+
trimmedCount++;
|
|
51
64
|
}
|
|
52
65
|
return trimmed;
|
|
53
66
|
}
|
|
@@ -76,7 +89,7 @@ export function upsertInitBlock(file, opts = {}) {
|
|
|
76
89
|
let next = body;
|
|
77
90
|
let status;
|
|
78
91
|
if (opts.remove) {
|
|
79
|
-
if (!exists || !
|
|
92
|
+
if (!exists || !MIGRATE_RE.test(body)) {
|
|
80
93
|
status = "absent";
|
|
81
94
|
}
|
|
82
95
|
else {
|
|
@@ -88,14 +101,32 @@ export function upsertInitBlock(file, opts = {}) {
|
|
|
88
101
|
next = `${block}${eol}`;
|
|
89
102
|
status = "created";
|
|
90
103
|
}
|
|
91
|
-
else if (
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
|
|
104
|
+
else if (MIGRATE_RE.test(body)) {
|
|
105
|
+
const matches = body.match(new RegExp(MIGRATE_RE.source, "g"));
|
|
106
|
+
if (matches && matches.length > 1) {
|
|
107
|
+
const firstIdx = body.search(MIGRATE_RE);
|
|
108
|
+
const replaced = body.replace(MIGRATE_RE, block);
|
|
109
|
+
const afterPos = firstIdx + block.length;
|
|
110
|
+
const head = replaced.slice(0, afterPos);
|
|
111
|
+
let tail = replaced.slice(afterPos);
|
|
112
|
+
let removed = 0;
|
|
113
|
+
while (removed < OWNER_CAP && MIGRATE_RE.test(tail)) {
|
|
114
|
+
tail = tail.replace(MIGRATE_RE, "");
|
|
115
|
+
removed++;
|
|
116
|
+
}
|
|
117
|
+
next = collapseBlankRuns(head + tail, eol);
|
|
118
|
+
status = "updated";
|
|
119
|
+
console.error(`collapsed ${removed} duplicate managed blocks in ${file}`);
|
|
95
120
|
}
|
|
96
121
|
else {
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
const current = body.match(MIGRATE_RE)?.[0] ?? "";
|
|
123
|
+
if (current === block) {
|
|
124
|
+
status = "ok";
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
next = body.replace(MIGRATE_RE, block);
|
|
128
|
+
status = "updated";
|
|
129
|
+
}
|
|
99
130
|
}
|
|
100
131
|
}
|
|
101
132
|
else {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Pure sub-agent first-line marker upsert (S8 / D20). Spec: Appendix A7 of
|
|
2
|
+
// docs/spec/dev-loop/orchestration-directive-architecture.md.
|
|
3
|
+
//
|
|
4
|
+
// `launch_agent` routes every sub-agent prompt through ensureParentMarker before
|
|
5
|
+
// spawn so the canonical child-session identifier is ALWAYS the literal first
|
|
6
|
+
// line — this is what makes the §6 first-line exemption fire and prevents the §5
|
|
7
|
+
// fail-safe-ON default from recursively orchestrating child sessions (fork-bomb
|
|
8
|
+
// prevention). Idempotent, silent, never mutates the prompt body.
|
|
9
|
+
export const MARKER = "<this is a request from a parent process>";
|
|
10
|
+
// Return `prompt` with MARKER guaranteed as its literal first line.
|
|
11
|
+
// - First line = position 0 up to the first "\n"; a trailing "\r" (CRLF) is
|
|
12
|
+
// stripped before comparison only.
|
|
13
|
+
// - For the startsWith(MARKER) check ONLY, a leading BOM (U+FEFF) on the first
|
|
14
|
+
// line is ignored. The prompt body is NEVER mutated.
|
|
15
|
+
// - Leading whitespace is NOT stripped: the spec is "literal first line begins
|
|
16
|
+
// with MARKER" (D19), so a space-prefixed or line-2 marker counts as ABSENT.
|
|
17
|
+
// - Present (after BOM-strip) -> returned unchanged (no duplicate).
|
|
18
|
+
// - Absent -> MARKER + "\n" + prompt.
|
|
19
|
+
export function ensureParentMarker(prompt) {
|
|
20
|
+
const nl = prompt.indexOf("\n");
|
|
21
|
+
let firstLine = nl === -1 ? prompt : prompt.slice(0, nl);
|
|
22
|
+
if (firstLine.endsWith("\r"))
|
|
23
|
+
firstLine = firstLine.slice(0, -1);
|
|
24
|
+
if (firstLine.charCodeAt(0) === 0xfeff)
|
|
25
|
+
firstLine = firstLine.slice(1);
|
|
26
|
+
if (firstLine.startsWith(MARKER))
|
|
27
|
+
return prompt;
|
|
28
|
+
return MARKER + "\n" + prompt;
|
|
29
|
+
}
|
|
@@ -11,7 +11,7 @@ import * as reminder from "./reminder.js";
|
|
|
11
11
|
* turn) READS the marker here and decides what to inject. The hook now emits in
|
|
12
12
|
* BOTH marker states, on a per-prompt counter (reminder.ts): every
|
|
13
13
|
* REMINDER_PERIOD-th prompt injects the LONG mode-specific
|
|
14
|
-
* <
|
|
14
|
+
* <subagent-mcp> reminder block, every prompt between injects the
|
|
15
15
|
* one-line rule carrier. Marker ON adds the claim machinery: the claim turn
|
|
16
16
|
* (fresh enable or carryover re-claim) emits the FULL directive plus the ON
|
|
17
17
|
* reminder block and re-baselines the counter. (Supersedes LOCKED DECISION 2's
|
|
@@ -1,50 +1,5 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { cwdHash, stateDir } from "./marker.js";
|
|
5
1
|
import { serverAlive } from "./liveness.js";
|
|
6
|
-
import { sessionKey } from "./hook-core.js";
|
|
7
|
-
export const INLINE_TOOL_LIMIT = 5;
|
|
8
2
|
const NATIVE_SUBAGENT_TOOLS = new Set(["Task", "Agent", "Explore"]);
|
|
9
|
-
const QUESTION_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
10
|
-
const SUBAGENT_MCP_TOOL_RE = /(^|__)subagent[-_]?mcp(__|$).*(launch_agent|poll_agent|wait|list_agents|send_message|kill_agent)$/i;
|
|
11
|
-
function hash(s) {
|
|
12
|
-
return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
|
|
13
|
-
}
|
|
14
|
-
function owner(payload) {
|
|
15
|
-
return sessionKey(payload) ?? "null";
|
|
16
|
-
}
|
|
17
|
-
function countPath(payload) {
|
|
18
|
-
const cwd = payload.cwd || process.cwd();
|
|
19
|
-
return join(stateDir, `pretool-${cwdHash(cwd)}-${hash(owner(payload))}.json`);
|
|
20
|
-
}
|
|
21
|
-
function readCount(payload) {
|
|
22
|
-
try {
|
|
23
|
-
const parsed = JSON.parse(readFileSync(countPath(payload), "utf8"));
|
|
24
|
-
return typeof parsed.count === "number" && parsed.count >= 0 ? parsed.count : 0;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return 0;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
function writeCount(payload, count) {
|
|
31
|
-
try {
|
|
32
|
-
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
33
|
-
writeFileSync(countPath(payload), JSON.stringify({ owner: owner(payload), count, updated_at: Date.now() }), { encoding: "utf8", mode: 0o600 });
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
// Fail open: counter persistence is advisory enforcement, not host safety.
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
export function resetToolCount(payload) {
|
|
40
|
-
try {
|
|
41
|
-
if (existsSync(countPath(payload)))
|
|
42
|
-
unlinkSync(countPath(payload));
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
// Fail open.
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
3
|
function decision(permissionDecision, permissionDecisionReason, additionalContext) {
|
|
49
4
|
return {
|
|
50
5
|
hookSpecificOutput: {
|
|
@@ -55,20 +10,15 @@ function decision(permissionDecision, permissionDecisionReason, additionalContex
|
|
|
55
10
|
},
|
|
56
11
|
};
|
|
57
12
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
].includes(tool));
|
|
68
|
-
}
|
|
69
|
-
function exemptFromCounter(tool) {
|
|
70
|
-
return isSubagentMcpTool(tool) || QUESTION_TOOLS.has(tool);
|
|
71
|
-
}
|
|
13
|
+
/**
|
|
14
|
+
* Claude PreToolUse gate. The ONLY enforcement here is the sole-channel rule:
|
|
15
|
+
* deny harness-native Task/Agent/Explore while subagent-mcp is alive so all
|
|
16
|
+
* sub-agent launches route through launch_agent. There is NO inline tool-call
|
|
17
|
+
* counter — the old inline tool-call-count injection is gone (D11/D24).
|
|
18
|
+
* Long-horizon
|
|
19
|
+
* upgrades are now agent-self-driven via the OFF-mode cumulative footprint
|
|
20
|
+
* check (no hook-side counting).
|
|
21
|
+
*/
|
|
72
22
|
export function runClaudePreTool(payload, env, now = Date.now()) {
|
|
73
23
|
try {
|
|
74
24
|
if (env.SUBAGENT_MCP_SUBAGENT === "1")
|
|
@@ -81,13 +31,7 @@ export function runClaudePreTool(payload, env, now = Date.now()) {
|
|
|
81
31
|
if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
|
|
82
32
|
return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.");
|
|
83
33
|
}
|
|
84
|
-
|
|
85
|
-
return null;
|
|
86
|
-
const next = readCount(payload) + 1;
|
|
87
|
-
writeCount(payload, next);
|
|
88
|
-
if (next <= INLINE_TOOL_LIMIT)
|
|
89
|
-
return null;
|
|
90
|
-
return decision("ask", `5-CALL RULE: this is tool call ${next} for the current user request. If work remains, delegate through subagent-mcp launch_agent; allow only for main-session-only capability or tight verification.`, "5-CALL RULE reached. Route remaining non-main-session work through subagent-mcp launch_agent; inline only for main-session-only capability or tight verification.");
|
|
34
|
+
return null;
|
|
91
35
|
}
|
|
92
36
|
catch {
|
|
93
37
|
return null;
|