@heretyc/subagent-mcp 2.12.8 → 2.12.10
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/dist/concurrency.js +226 -30
- package/dist/config-scaffold.js +1 -1
- package/dist/drivers.js +52 -13
- package/dist/global-concurrency.jsonc +5 -1
- package/dist/global-subagent-mcp-config.jsonc +5 -1
- package/dist/hooks/orchestration-claude.js +6 -1
- package/dist/hooks/orchestration-codex.js +8 -16
- package/dist/index.js +65 -10
- package/dist/launch-prompt.js +13 -7
- package/dist/orchestration/atomic-write.js +31 -0
- package/dist/orchestration/hook-core.js +48 -16
- package/dist/orchestration/liveness.js +3 -2
- package/dist/orchestration/marker.js +67 -38
- package/dist/orchestration/model-mode.js +3 -2
- package/dist/orchestration/pretool.js +2 -2
- package/dist/orchestration/reminder.js +3 -2
- package/dist/orchestration/update-check.js +6 -7
- package/dist/output-helpers.js +5 -1
- package/dist/permission-engine.js +31 -3
- package/dist/zombie.js +61 -4
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { pendingPermissionManager, } from "./pending-permissions.js";
|
|
|
29
29
|
const agents = new Map();
|
|
30
30
|
const deadlockWindow = createDeadlockWindow();
|
|
31
31
|
const STDOUT_RING_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
export const AGENT_RETENTION_MS = 30 * 60 * 1000;
|
|
32
33
|
// Advanced-ruleset gate: per-process latch with exactly the deadlock-window
|
|
33
34
|
// scoping. The env-check runs lazily at the FIRST launch_agent call; success
|
|
34
35
|
// latches enabled/disabled for the process lifetime, failure never latches.
|
|
@@ -116,6 +117,15 @@ const TASK_CATEGORY_GLOSS = "REQUIRED. Task shape -> routing category (server pi
|
|
|
116
117
|
function errorResult(text) {
|
|
117
118
|
return { content: [{ type: "text", text }], isError: true };
|
|
118
119
|
}
|
|
120
|
+
function currentLaunchDepth(env = process.env) {
|
|
121
|
+
const raw = env.SUBAGENT_MCP_DEPTH;
|
|
122
|
+
if (raw !== undefined && raw !== "") {
|
|
123
|
+
const parsed = Number.parseInt(raw, 10);
|
|
124
|
+
if (Number.isInteger(parsed) && parsed >= 0 && String(parsed) === raw.trim())
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
return env.SUBAGENT_MCP_SUBAGENT === "1" ? 1 : 0;
|
|
128
|
+
}
|
|
119
129
|
function envDuration(name, fallback) {
|
|
120
130
|
const raw = process.env[name];
|
|
121
131
|
if (raw === undefined || raw === "")
|
|
@@ -161,6 +171,33 @@ function isLiveAgent(agent) {
|
|
|
161
171
|
agent.status === "permission_requested" ||
|
|
162
172
|
agent.status === "stalled");
|
|
163
173
|
}
|
|
174
|
+
function isTerminalAgentStatus(status) {
|
|
175
|
+
return (status === "finished" ||
|
|
176
|
+
status === "errored" ||
|
|
177
|
+
status === "stopped" ||
|
|
178
|
+
status === "zombie_killed");
|
|
179
|
+
}
|
|
180
|
+
export function shouldEvictAgent(agent, now = Date.now(), retentionMs = AGENT_RETENTION_MS) {
|
|
181
|
+
if (!isTerminalAgentStatus(agent.status))
|
|
182
|
+
return false;
|
|
183
|
+
if (!agent.driver.closed)
|
|
184
|
+
return false;
|
|
185
|
+
if (!agent.waitReported)
|
|
186
|
+
return false;
|
|
187
|
+
if (agent.exitedAt === null)
|
|
188
|
+
return false;
|
|
189
|
+
return now - agent.exitedAt > retentionMs;
|
|
190
|
+
}
|
|
191
|
+
export function evictExpiredAgents(agentMap, now = Date.now(), retentionMs = AGENT_RETENTION_MS) {
|
|
192
|
+
let evicted = 0;
|
|
193
|
+
for (const [id, agent] of agentMap) {
|
|
194
|
+
if (!shouldEvictAgent(agent, now, retentionMs))
|
|
195
|
+
continue;
|
|
196
|
+
agentMap.delete(id);
|
|
197
|
+
evicted++;
|
|
198
|
+
}
|
|
199
|
+
return evicted;
|
|
200
|
+
}
|
|
164
201
|
function isSameOwnerSlot(agent, slotMeta) {
|
|
165
202
|
if (!slotMeta)
|
|
166
203
|
return true;
|
|
@@ -321,6 +358,7 @@ function runToolMaintenance() {
|
|
|
321
358
|
}
|
|
322
359
|
updateSlotMetadata(agent);
|
|
323
360
|
}
|
|
361
|
+
evictExpiredAgents(agents, now);
|
|
324
362
|
return records;
|
|
325
363
|
}
|
|
326
364
|
function withMaintenance(handler, options = {}) {
|
|
@@ -500,6 +538,7 @@ const reconcileInterval = setInterval(() => {
|
|
|
500
538
|
});
|
|
501
539
|
}
|
|
502
540
|
}
|
|
541
|
+
evictExpiredAgents(agents, now);
|
|
503
542
|
}, 10000);
|
|
504
543
|
reconcileInterval.unref();
|
|
505
544
|
// Heavy operating-model + governance guidance for ORCHESTRATION MODE. Carried in
|
|
@@ -512,10 +551,10 @@ reconcileInterval.unref();
|
|
|
512
551
|
// compressed under MCP metadata limits:
|
|
513
552
|
// 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.
|
|
514
553
|
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 jointly binding; genuine conflict => STOP and ask. Only the hook flips ON/OFF; absence of any tag = UNKNOWN => fail-safe ON.\n\nSOLE CHANNEL. Every 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 /workflows. No direct reads/writes; inline-by-right does not exist. Non-delegable step: ask a one-time exception, do only that step, resume delegating.\n\nSUB-AGENT CONTRACT. Every prompt carries objective + output format + tools/sources + boundaries. SCALE: ~1 agent for a fact-find, 2-4 for comparisons; never one-shot multi-phase work; split into atomic steps, one agent each. FAN-OUT independents, sequence dependents, SERIALIZE writers over shared paths (no cwd lock). VERIFY code and non-trivial steps with a separate sub-agent first.\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 them. Empty/stalled tail means ALIVE; learn finish via wait, do not poll-loop.\n\nORCHESTRATION OFF. If context footprint since last upgrade ask exceeds 200 lines, after that turn STOP and ask whether to enable; reset count only when you ask.\n\nDROPOUT WHILE ON: HALT and ask until restored. SUB-AGENT EXEMPTION: a prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" skips this regime. DISABLE: user-only, never on your own initiative.\n\nMODEL SELECTION. Default smart auto-picks, rejects provider/model/effort selectors. user-approved-overrides honors them 30 min, expires lazily on launch_agent, needs user authorization.";
|
|
515
|
-
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.";
|
|
554
|
+
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. launch_agent is code-capped at 2 spawn levels below the main orchestrator: depth 1 may launch depth 2 workers; depth 2 workers cannot spawn further.\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.";
|
|
516
555
|
const server = new McpServer({
|
|
517
556
|
name: "subagent-mcp",
|
|
518
|
-
version: "2.12.
|
|
557
|
+
version: "2.12.10",
|
|
519
558
|
description: "Launches always-interactive local Claude and Codex sub-agent sessions and is the orchestrator's sole launch channel. Claude runs via the Claude Agent SDK over the local Claude Code executable; Codex via `codex app-server` over stdio. The server never calls Anthropic or OpenAI HTTP APIs directly.",
|
|
520
559
|
}, {
|
|
521
560
|
instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
|
|
@@ -580,7 +619,11 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapsho
|
|
|
580
619
|
command: cmd,
|
|
581
620
|
args: buildResult.args,
|
|
582
621
|
cwd: agentCwd,
|
|
583
|
-
env: {
|
|
622
|
+
env: {
|
|
623
|
+
...process.env,
|
|
624
|
+
SUBAGENT_MCP_SUBAGENT: "1",
|
|
625
|
+
SUBAGENT_MCP_DEPTH: String(currentLaunchDepth() + 1),
|
|
626
|
+
},
|
|
584
627
|
model: candidate.model,
|
|
585
628
|
effort: candidate.effort,
|
|
586
629
|
ucSettingsPath: buildResult.ucSettingsPath,
|
|
@@ -894,6 +937,10 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
|
|
|
894
937
|
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."),
|
|
895
938
|
}, withMaintenance(async (params) => {
|
|
896
939
|
const { task_category, provider, model, effort, deadlock } = params;
|
|
940
|
+
const launchDepth = currentLaunchDepth();
|
|
941
|
+
if (launchDepth >= 2) {
|
|
942
|
+
return errorResult(`Error: launch_agent depth cap reached: current SUBAGENT_MCP_DEPTH=${launchDepth}. subagent-mcp permits exactly 2 spawn levels below the main orchestrator (depth 0 -> 1 -> 2); depth 2 workers cannot spawn further sub-agents.`);
|
|
943
|
+
}
|
|
897
944
|
// D19/D20/S8: server silently upserts the parent-process marker as the TRUE
|
|
898
945
|
// first line of every sub-agent prompt (idempotent; never duplicates; never
|
|
899
946
|
// mutates the body). This is what makes the child first-line exemption fire.
|
|
@@ -1567,7 +1614,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1567
1614
|
};
|
|
1568
1615
|
}));
|
|
1569
1616
|
// Tool 8: orchestration-mode
|
|
1570
|
-
server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF for THIS session only, omit = query. SOLE CHANNEL holds in BOTH states: subagent-mcp is the only sanctioned way to launch sub-agents; toggling OFF does not lift that. WHAT: default-ON mode for LONG-HORIZON work that would fill the context window if run inline; when ON act as a delegate-ONLY orchestrator — delegate every step, inline-by-right does not exist, a non-delegable atomic step needs a one-time user-approved exception via the structured-question tool (state which + why). OFF 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 whether to remain enabled (every qualifying turn; a decline does not latch; reset the count only when you ask). PERSISTENCE: a permitted disable applies to THIS session only, resumes ON next new session (or after the 2h backstop), no mid-session re-enable. CARRYOVER: if ON was inherited from a prior session (carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker) — notify the user it auto-activated and confirm keeping it ON. DISABLE: never on your own initiative; you may PROPOSE 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). Full operating model is in this server's MCP `instructions` (read once at initialize) — this is the operational summary only.", {
|
|
1617
|
+
server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF for THIS session only, omit = query. SOLE CHANNEL holds in BOTH states: subagent-mcp is the only sanctioned way to launch sub-agents; toggling OFF does not lift that. WHAT: default-ON mode for LONG-HORIZON work that would fill the context window if run inline; when ON act as a delegate-ONLY orchestrator — delegate every step, inline-by-right does not exist, a non-delegable atomic step needs a one-time user-approved exception via the structured-question tool (state which + why). OFF 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 whether to remain enabled (every qualifying turn; a decline does not latch; reset the count only when you ask). PERSISTENCE: a permitted disable is session-keyed only, applies to THIS session only, resumes ON next new session (or after the 2h backstop), no mid-session re-enable; keyless hosts get only the one-time non-persisted conversational opt-out. CARRYOVER: if ON was inherited from a prior session (carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker) — notify the user it auto-activated and confirm keeping it ON. DISABLE: never on your own initiative; you may PROPOSE 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). Full operating model is in this server's MCP `instructions` (read once at initialize) — this is the operational summary only.", {
|
|
1571
1618
|
enabled: z.boolean().optional(),
|
|
1572
1619
|
}, withMaintenance(async (params) => {
|
|
1573
1620
|
const cwd = process.cwd();
|
|
@@ -1589,10 +1636,13 @@ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MOD
|
|
|
1589
1636
|
};
|
|
1590
1637
|
}
|
|
1591
1638
|
else if (params.enabled === false) {
|
|
1592
|
-
if (key)
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1639
|
+
if (!key) {
|
|
1640
|
+
return errorResult("cannot disable: no session pointer found for this project (the per-turn hook has not fired yet). Disable records are session-keyed only; send one prompt first, or check wiring with `subagent-mcp doctor`.");
|
|
1641
|
+
}
|
|
1642
|
+
if (!orchestrationMarker.isSessionScopedKey(key)) {
|
|
1643
|
+
return errorResult("cannot disable: this host supplies no session identity (no session_id/transcript_path in hook payloads) and disable records are session-keyed only. Offer the user the one-time, non-persisted conversational opt-out for this window; orchestration stays ON.");
|
|
1644
|
+
}
|
|
1645
|
+
orchestrationMarker.writeDisable(key);
|
|
1596
1646
|
return {
|
|
1597
1647
|
content: [
|
|
1598
1648
|
{
|
|
@@ -1613,6 +1663,11 @@ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MOD
|
|
|
1613
1663
|
type: "text",
|
|
1614
1664
|
text: JSON.stringify({
|
|
1615
1665
|
orchestration_mode: active ? "ON" : "disabled-this-session",
|
|
1666
|
+
session_scope: key
|
|
1667
|
+
? orchestrationMarker.isSessionScopedKey(key)
|
|
1668
|
+
? "session"
|
|
1669
|
+
: "anonymous"
|
|
1670
|
+
: "none",
|
|
1616
1671
|
}),
|
|
1617
1672
|
},
|
|
1618
1673
|
],
|
|
@@ -1820,8 +1875,8 @@ if (isMain) {
|
|
|
1820
1875
|
// explicit user permission. On a new session a carried-over legacy ON marker
|
|
1821
1876
|
// (if any) triggers a one-time prompt asking whether to remain enabled; under
|
|
1822
1877
|
// default-ON this rarely fires.
|
|
1823
|
-
// (the tool's enabled:false writes a disable record via
|
|
1824
|
-
//
|
|
1878
|
+
// (the tool's enabled:false writes only a session-keyed disable record via
|
|
1879
|
+
// writeDisable; keyless persistent-disable requests are refused.)
|
|
1825
1880
|
getNpmPrefix();
|
|
1826
1881
|
void checkForNpmUpdate().catch(() => { });
|
|
1827
1882
|
startLivenessHeartbeat();
|
package/dist/launch-prompt.js
CHANGED
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
// fail-safe-ON default from recursively orchestrating child sessions (fork-bomb
|
|
8
8
|
// prevention). Idempotent, silent, never mutates the prompt body.
|
|
9
9
|
export const MARKER = "<this is a request from a parent process>";
|
|
10
|
+
export function hasParentMarker(prompt) {
|
|
11
|
+
if (typeof prompt !== "string")
|
|
12
|
+
return false;
|
|
13
|
+
const head = prompt.slice(0, 4096);
|
|
14
|
+
const nl = head.indexOf("\n");
|
|
15
|
+
let firstLine = nl === -1 ? head : head.slice(0, nl);
|
|
16
|
+
if (firstLine.endsWith("\r"))
|
|
17
|
+
firstLine = firstLine.slice(0, -1);
|
|
18
|
+
if (firstLine.charCodeAt(0) === 0xfeff)
|
|
19
|
+
firstLine = firstLine.slice(1);
|
|
20
|
+
return firstLine.startsWith(MARKER);
|
|
21
|
+
}
|
|
10
22
|
// Return `prompt` with MARKER guaranteed as its literal first line.
|
|
11
23
|
// - First line = position 0 up to the first "\n"; a trailing "\r" (CRLF) is
|
|
12
24
|
// stripped before comparison only.
|
|
@@ -17,13 +29,7 @@ export const MARKER = "<this is a request from a parent process>";
|
|
|
17
29
|
// - Present (after BOM-strip) -> returned unchanged (no duplicate).
|
|
18
30
|
// - Absent -> MARKER + "\n" + prompt.
|
|
19
31
|
export function ensureParentMarker(prompt) {
|
|
20
|
-
|
|
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))
|
|
32
|
+
if (hasParentMarker(prompt))
|
|
27
33
|
return prompt;
|
|
28
34
|
return MARKER + "\n" + prompt;
|
|
29
35
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
export function atomicWriteFile(path, data, options) {
|
|
4
|
+
const dir = dirname(path);
|
|
5
|
+
const tmp = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
6
|
+
try {
|
|
7
|
+
writeFileSync(tmp, data, options);
|
|
8
|
+
try {
|
|
9
|
+
renameSync(tmp, path);
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
const code = e?.code;
|
|
13
|
+
if (code !== "EEXIST" && code !== "EPERM")
|
|
14
|
+
throw e;
|
|
15
|
+
unlinkSync(path);
|
|
16
|
+
renameSync(tmp, path);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
try {
|
|
21
|
+
unlinkSync(tmp);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Best-effort cleanup only; preserve the original write/rename error.
|
|
25
|
+
}
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function atomicWriteJson(path, value, options) {
|
|
30
|
+
atomicWriteFile(path, JSON.stringify(value), options);
|
|
31
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
|
|
3
2
|
import { fileURLToPath } from "node:url";
|
|
4
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
@@ -30,6 +29,8 @@ import { appendUpdateNotice, readInstalledPackageInfo, } from "./update-check.js
|
|
|
30
29
|
*/
|
|
31
30
|
/** Long-reminder cadence: every Nth counted prompt is a LONG turn. */
|
|
32
31
|
export const REMINDER_PERIOD = 5;
|
|
32
|
+
export const ANON_CLAIM_TTL_MS = 2 * 60 * 60 * 1000;
|
|
33
|
+
const OWNER_CLAIM_CAP = 8;
|
|
33
34
|
/**
|
|
34
35
|
* Resolve the repo-root `directives/` dir at runtime. Honors an explicit plugin
|
|
35
36
|
* root (Claude sets CLAUDE_PLUGIN_ROOT; a generic PLUGIN_ROOT is also accepted)
|
|
@@ -161,29 +162,62 @@ export function countJsonlType(transcriptPath, wantedType) {
|
|
|
161
162
|
* sticky without changing classifyClaim's string/undefined contract.
|
|
162
163
|
*/
|
|
163
164
|
export function sessionKey(payload) {
|
|
164
|
-
if (typeof payload.session_id === "string") {
|
|
165
|
+
if (typeof payload.session_id === "string" && payload.session_id.length > 0) {
|
|
165
166
|
return payload.session_id;
|
|
166
167
|
}
|
|
167
168
|
if (typeof payload.transcript_path === "string" &&
|
|
168
169
|
payload.transcript_path.length > 0) {
|
|
169
|
-
return
|
|
170
|
-
createHash("sha256")
|
|
171
|
-
.update(payload.transcript_path, "utf8")
|
|
172
|
-
.digest("hex")
|
|
173
|
-
.slice(0, 16));
|
|
170
|
+
return "tp-" + marker.hashKey(payload.transcript_path);
|
|
174
171
|
}
|
|
175
172
|
return undefined;
|
|
176
173
|
}
|
|
177
|
-
export function
|
|
174
|
+
export function ownerKey(payload, cwd, adapter) {
|
|
175
|
+
return sessionKey(payload) ?? marker.anonKey(cwd, adapter.anonScope);
|
|
176
|
+
}
|
|
177
|
+
export function classifyClaim(owner_session, baseline_turn, current, claimed_at = null, now = Date.now()) {
|
|
178
178
|
if (baseline_turn == null || owner_session == null) {
|
|
179
179
|
return "fresh";
|
|
180
180
|
}
|
|
181
|
-
|
|
182
|
-
if (current === undefined || owner_session !== current) {
|
|
181
|
+
if (owner_session !== current) {
|
|
183
182
|
return "carryover";
|
|
184
183
|
}
|
|
184
|
+
if (!marker.isSessionScopedKey(current)) {
|
|
185
|
+
const age = typeof claimed_at === "number" ? now - claimed_at : Number.NaN;
|
|
186
|
+
if (!Number.isFinite(age) || age < 0 || age > ANON_CLAIM_TTL_MS) {
|
|
187
|
+
return "fresh";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
185
190
|
return "same";
|
|
186
191
|
}
|
|
192
|
+
export function classifyOwnerClaim(m, owner, now = Date.now()) {
|
|
193
|
+
const claim = m.owners?.[owner];
|
|
194
|
+
if (claim) {
|
|
195
|
+
return classifyClaim(owner, claim.baseline_turn, owner, claim.claimed_at, now);
|
|
196
|
+
}
|
|
197
|
+
const hasLiveOwner = m.owners !== undefined && Object.keys(m.owners).length > 0;
|
|
198
|
+
if (hasLiveOwner || typeof m.owner_session === "string") {
|
|
199
|
+
return "carryover";
|
|
200
|
+
}
|
|
201
|
+
return "fresh";
|
|
202
|
+
}
|
|
203
|
+
function claimOwner(m, owner, turn, now) {
|
|
204
|
+
const owners = { ...(m.owners ?? {}) };
|
|
205
|
+
owners[owner] = { baseline_turn: turn, claimed_at: now };
|
|
206
|
+
const entries = Object.entries(owners).sort((a, b) => {
|
|
207
|
+
const at = a[1].claimed_at ?? 0;
|
|
208
|
+
const bt = b[1].claimed_at ?? 0;
|
|
209
|
+
return at - bt;
|
|
210
|
+
});
|
|
211
|
+
while (entries.length > OWNER_CLAIM_CAP) {
|
|
212
|
+
const [oldest] = entries.shift() ?? [];
|
|
213
|
+
if (oldest)
|
|
214
|
+
delete owners[oldest];
|
|
215
|
+
}
|
|
216
|
+
m.owners = owners;
|
|
217
|
+
m.owner_session = owner;
|
|
218
|
+
m.baseline_turn = turn;
|
|
219
|
+
m.claimed_at = now;
|
|
220
|
+
}
|
|
187
221
|
/**
|
|
188
222
|
* Per-prompt reminder cadence emission: the LONG block (longFile) on every
|
|
189
223
|
* REMINDER_PERIOD-th counted prompt, the one-line rule carrier between. When the
|
|
@@ -208,8 +242,7 @@ function cadenceEmit(env, adapter, longFile, shortFile, count, persisted) {
|
|
|
208
242
|
*/
|
|
209
243
|
export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
|
|
210
244
|
const firstCarryover = kind === "carryover" && !m.carryover_ack;
|
|
211
|
-
m
|
|
212
|
-
m.owner_session = current ?? null;
|
|
245
|
+
claimOwner(m, current, turn, Date.now());
|
|
213
246
|
if (kind === "carryover") {
|
|
214
247
|
m.provenance = "carried-over";
|
|
215
248
|
m.carryover_ack = true;
|
|
@@ -273,17 +306,16 @@ export function runHook(payload, env, adapter) {
|
|
|
273
306
|
return "";
|
|
274
307
|
}
|
|
275
308
|
const cwd = payload.cwd || process.cwd();
|
|
276
|
-
const current =
|
|
309
|
+
const current = ownerKey(payload, cwd, adapter);
|
|
277
310
|
const updateNoticeSessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
|
|
278
|
-
|
|
279
|
-
marker.writeCurrentSession(cwd, current);
|
|
311
|
+
marker.writeCurrentSession(cwd, current);
|
|
280
312
|
if (!marker.isActive(cwd, current)) {
|
|
281
313
|
// OFF: no claim machinery — just the per-prompt reminder cadence.
|
|
282
314
|
const r = reminder.advance(cwd, current);
|
|
283
315
|
return appendHookUpdateNotice(cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted), updateNoticeSessionId, env);
|
|
284
316
|
}
|
|
285
317
|
const m = marker.readMarker(cwd);
|
|
286
|
-
const kind =
|
|
318
|
+
const kind = classifyOwnerClaim(m, current);
|
|
287
319
|
if (kind === "fresh" || kind === "carryover") {
|
|
288
320
|
const turn = adapter.currentTurn(payload.transcript_path);
|
|
289
321
|
return appendHookUpdateNotice(claimAndEmit(cwd, current, turn, m, kind, env, adapter), updateNoticeSessionId, env);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, statSync
|
|
1
|
+
import { mkdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "./atomic-write.js";
|
|
3
4
|
import { stateDir } from "./marker.js";
|
|
4
5
|
export const LIVENESS_TTL_MS = 120_000;
|
|
5
6
|
export const LIVENESS_INTERVAL_MS = 30_000;
|
|
@@ -9,7 +10,7 @@ export function alivePath() {
|
|
|
9
10
|
export function touchAlive(now = Date.now()) {
|
|
10
11
|
try {
|
|
11
12
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
12
|
-
|
|
13
|
+
atomicWriteFile(alivePath(), `${now}\n`, { encoding: "utf8", mode: 0o600 });
|
|
13
14
|
}
|
|
14
15
|
catch {
|
|
15
16
|
// Hooks fail open when liveness cannot be observed.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync,
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { atomicWriteJson } from "./atomic-write.js";
|
|
5
6
|
const markerDir = join(tmpdir(), "subagent-mcp");
|
|
6
7
|
export const ORCH_DISABLE_TTL_MS = 2 * 60 * 60 * 1000; // 2h GC backstop ONLY (independent of model-mode WINDOW_MS)
|
|
8
|
+
export const ANON_PREFIX = "anon-";
|
|
7
9
|
/**
|
|
8
10
|
* Shared per-project state dir for ALL hook state files (marker + reminder
|
|
9
11
|
* counter). Exported so sibling state modules key off the SAME location — a
|
|
@@ -34,7 +36,7 @@ export function normalizeCwd(cwd) {
|
|
|
34
36
|
}
|
|
35
37
|
return p;
|
|
36
38
|
}
|
|
37
|
-
function hashKey(key) {
|
|
39
|
+
export function hashKey(key) {
|
|
38
40
|
return createHash("sha256")
|
|
39
41
|
.update(key, "utf8")
|
|
40
42
|
.digest("hex")
|
|
@@ -43,6 +45,12 @@ function hashKey(key) {
|
|
|
43
45
|
export function cwdHash(cwd) {
|
|
44
46
|
return hashKey(normalizeCwd(cwd));
|
|
45
47
|
}
|
|
48
|
+
export function anonKey(cwd, scope) {
|
|
49
|
+
return `${ANON_PREFIX}${scope}-${cwdHash(cwd)}`;
|
|
50
|
+
}
|
|
51
|
+
export function isSessionScopedKey(key) {
|
|
52
|
+
return !key.startsWith(ANON_PREFIX);
|
|
53
|
+
}
|
|
46
54
|
export function markerPath(cwd) {
|
|
47
55
|
return join(markerDir, "orch-" + cwdHash(cwd) + ".flag");
|
|
48
56
|
}
|
|
@@ -74,10 +82,12 @@ export function enable(cwd) {
|
|
|
74
82
|
const state = {
|
|
75
83
|
owner_session: null,
|
|
76
84
|
baseline_turn: null,
|
|
85
|
+
claimed_at: null,
|
|
86
|
+
owners: {},
|
|
77
87
|
provenance: "user-enabled",
|
|
78
88
|
carryover_ack: false,
|
|
79
89
|
};
|
|
80
|
-
|
|
90
|
+
atomicWriteJson(markerPath(cwd), state, { encoding: "utf8", mode: 0o600 });
|
|
81
91
|
try {
|
|
82
92
|
unlinkSync(cwdDisablePath(cwd));
|
|
83
93
|
}
|
|
@@ -91,26 +101,12 @@ export function enable(cwd) {
|
|
|
91
101
|
// Fail-safe: never throw to the caller.
|
|
92
102
|
}
|
|
93
103
|
}
|
|
94
|
-
/**
|
|
95
|
-
* Disable orchestration for cwd using cwd-keyed shared fallback state. The
|
|
96
|
-
* hook's session-keyed path uses writeDisable(sessionKey) instead.
|
|
97
|
-
*/
|
|
98
|
-
export function disable(cwd) {
|
|
99
|
-
try {
|
|
100
|
-
mkdirSync(markerDir, { recursive: true, mode: 0o700 });
|
|
101
|
-
writeFileSync(cwdDisablePath(cwd), JSON.stringify({ disabled_at: Date.now() }), {
|
|
102
|
-
encoding: "utf8",
|
|
103
|
-
mode: 0o600,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
// Fail-safe: never throw to the caller.
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
104
|
export function writeDisable(sessionKey) {
|
|
105
|
+
if (!isSessionScopedKey(sessionKey))
|
|
106
|
+
return;
|
|
111
107
|
try {
|
|
112
108
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
113
|
-
|
|
109
|
+
atomicWriteJson(disablePath(sessionKey), { disabled_at: Date.now() }, {
|
|
114
110
|
encoding: "utf8",
|
|
115
111
|
mode: 0o600,
|
|
116
112
|
});
|
|
@@ -119,13 +115,9 @@ export function writeDisable(sessionKey) {
|
|
|
119
115
|
// Fail-safe: never throw to the caller.
|
|
120
116
|
}
|
|
121
117
|
}
|
|
122
|
-
export function
|
|
118
|
+
export function removeDisable(sessionKey) {
|
|
123
119
|
try {
|
|
124
|
-
|
|
125
|
-
writeFileSync(cwdDisablePath(cwd), JSON.stringify({ disabled_at: Date.now() }), {
|
|
126
|
-
encoding: "utf8",
|
|
127
|
-
mode: 0o600,
|
|
128
|
-
});
|
|
120
|
+
unlinkSync(disablePath(sessionKey));
|
|
129
121
|
}
|
|
130
122
|
catch {
|
|
131
123
|
// Fail-safe: never throw to the caller.
|
|
@@ -134,13 +126,13 @@ export function writeDisableCwd(cwd) {
|
|
|
134
126
|
export function writeCurrentSession(cwd, sessionKey, serverKey = process.ppid) {
|
|
135
127
|
try {
|
|
136
128
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
137
|
-
|
|
129
|
+
atomicWriteJson(serverSessionPointerPath(cwd, serverKey), { session_key: sessionKey }, {
|
|
138
130
|
encoding: "utf8",
|
|
139
131
|
mode: 0o600,
|
|
140
132
|
});
|
|
141
133
|
// Back-compat only: older consumers may still read the cwd-keyed pointer.
|
|
142
134
|
// New disable/query paths must read the server-scoped pointer instead.
|
|
143
|
-
|
|
135
|
+
atomicWriteJson(sessionPointerPath(cwd), { session_key: sessionKey }, {
|
|
144
136
|
encoding: "utf8",
|
|
145
137
|
mode: 0o600,
|
|
146
138
|
});
|
|
@@ -153,6 +145,15 @@ export function readCurrentSession(cwd, serverKey = process.ppid) {
|
|
|
153
145
|
try {
|
|
154
146
|
const raw = readFileSync(serverSessionPointerPath(cwd, serverKey), "utf8");
|
|
155
147
|
const parsed = JSON.parse(raw);
|
|
148
|
+
if (typeof parsed.session_key === "string")
|
|
149
|
+
return parsed.session_key;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Fall through to legacy cwd pointer below.
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const raw = readFileSync(sessionPointerPath(cwd), "utf8");
|
|
156
|
+
const parsed = JSON.parse(raw);
|
|
156
157
|
return typeof parsed.session_key === "string" ? parsed.session_key : undefined;
|
|
157
158
|
}
|
|
158
159
|
catch {
|
|
@@ -177,13 +178,42 @@ function isDisableActive(path, now) {
|
|
|
177
178
|
}
|
|
178
179
|
export function isActive(cwd, sessionKey) {
|
|
179
180
|
try {
|
|
180
|
-
|
|
181
|
-
|
|
181
|
+
if (sessionKey === undefined || !isSessionScopedKey(sessionKey))
|
|
182
|
+
return true;
|
|
183
|
+
return !isDisableActive(disablePath(sessionKey), Date.now());
|
|
182
184
|
}
|
|
183
185
|
catch {
|
|
184
186
|
return true;
|
|
185
187
|
}
|
|
186
188
|
}
|
|
189
|
+
function readOwners(parsed) {
|
|
190
|
+
const owners = {};
|
|
191
|
+
if (parsed.owners && typeof parsed.owners === "object") {
|
|
192
|
+
for (const [owner, claim] of Object.entries(parsed.owners)) {
|
|
193
|
+
if (!owner || !claim || typeof claim !== "object")
|
|
194
|
+
continue;
|
|
195
|
+
const baseline = claim.baseline_turn;
|
|
196
|
+
const claimed = claim.claimed_at;
|
|
197
|
+
if (typeof baseline !== "number" || !Number.isFinite(baseline))
|
|
198
|
+
continue;
|
|
199
|
+
owners[owner] = {
|
|
200
|
+
baseline_turn: baseline,
|
|
201
|
+
claimed_at: typeof claimed === "number" && Number.isFinite(claimed) ? claimed : null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (Object.keys(owners).length === 0 &&
|
|
206
|
+
typeof parsed.owner_session === "string" &&
|
|
207
|
+
typeof parsed.baseline_turn === "number" &&
|
|
208
|
+
Number.isFinite(parsed.baseline_turn)) {
|
|
209
|
+
const claimed = parsed.claimed_at;
|
|
210
|
+
owners[parsed.owner_session] = {
|
|
211
|
+
baseline_turn: parsed.baseline_turn,
|
|
212
|
+
claimed_at: typeof claimed === "number" && Number.isFinite(claimed) ? claimed : null,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return owners;
|
|
216
|
+
}
|
|
187
217
|
export function readMarker(cwd) {
|
|
188
218
|
try {
|
|
189
219
|
const raw = readFileSync(markerPath(cwd), "utf8");
|
|
@@ -194,6 +224,10 @@ export function readMarker(cwd) {
|
|
|
194
224
|
return {
|
|
195
225
|
owner_session: typeof parsed.owner_session === "string" ? parsed.owner_session : null,
|
|
196
226
|
baseline_turn: typeof parsed.baseline_turn === "number" ? parsed.baseline_turn : null,
|
|
227
|
+
claimed_at: typeof parsed.claimed_at === "number" && Number.isFinite(parsed.claimed_at)
|
|
228
|
+
? parsed.claimed_at
|
|
229
|
+
: null,
|
|
230
|
+
owners: readOwners(parsed),
|
|
197
231
|
provenance,
|
|
198
232
|
carryover_ack: typeof parsed.carryover_ack === "boolean" ? parsed.carryover_ack : false,
|
|
199
233
|
};
|
|
@@ -203,6 +237,8 @@ export function readMarker(cwd) {
|
|
|
203
237
|
return {
|
|
204
238
|
owner_session: null,
|
|
205
239
|
baseline_turn: null,
|
|
240
|
+
claimed_at: null,
|
|
241
|
+
owners: {},
|
|
206
242
|
provenance: null,
|
|
207
243
|
carryover_ack: false,
|
|
208
244
|
};
|
|
@@ -212,16 +248,9 @@ export function writeMarker(cwd, obj) {
|
|
|
212
248
|
try {
|
|
213
249
|
// Owner-only perms (see enable()): the marker persists owner_session.
|
|
214
250
|
mkdirSync(markerDir, { recursive: true, mode: 0o700 });
|
|
215
|
-
|
|
251
|
+
atomicWriteJson(markerPath(cwd), obj, { encoding: "utf8", mode: 0o600 });
|
|
216
252
|
}
|
|
217
253
|
catch {
|
|
218
254
|
// Fail-safe.
|
|
219
255
|
}
|
|
220
256
|
}
|
|
221
|
-
/**
|
|
222
|
-
* Cwd-keyed disable alias, identical to disable. RETAINED for callers that
|
|
223
|
-
* clear legacy marker state explicitly (e.g. the tool's enabled:false path).
|
|
224
|
-
*/
|
|
225
|
-
export function clearForCwd(cwd) {
|
|
226
|
-
disable(cwd);
|
|
227
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteJson } from "./atomic-write.js";
|
|
3
4
|
import { cwdHash, stateDir } from "./marker.js";
|
|
4
5
|
/**
|
|
5
6
|
* Per-project model-selection mode — the SINGLE source of truth for whether a
|
|
@@ -37,7 +38,7 @@ function writeModelMode(cwd, state) {
|
|
|
37
38
|
try {
|
|
38
39
|
// Owner-only perms (mirror marker.ts): the file persists an enable-time.
|
|
39
40
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
40
|
-
|
|
41
|
+
atomicWriteJson(modelModePath(cwd), state, {
|
|
41
42
|
encoding: "utf8",
|
|
42
43
|
mode: 0o600,
|
|
43
44
|
});
|
|
@@ -23,8 +23,6 @@ function decision(permissionDecision, permissionDecisionReason, additionalContex
|
|
|
23
23
|
export function runClaudePreTool(payload, env, now = Date.now()) {
|
|
24
24
|
try {
|
|
25
25
|
const zombieRecords = cullHookZombies();
|
|
26
|
-
if (env.SUBAGENT_MCP_SUBAGENT === "1")
|
|
27
|
-
return null;
|
|
28
26
|
const maintenanceAllowedDecision = zombieRecords.length > 0
|
|
29
27
|
? decision("allow", "maintenance completed; allowing requested tool.")
|
|
30
28
|
: null;
|
|
@@ -36,6 +34,8 @@ export function runClaudePreTool(payload, env, now = Date.now()) {
|
|
|
36
34
|
if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
|
|
37
35
|
return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.");
|
|
38
36
|
}
|
|
37
|
+
if (env.SUBAGENT_MCP_SUBAGENT === "1")
|
|
38
|
+
return maintenanceAllowedDecision;
|
|
39
39
|
return maintenanceAllowedDecision;
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteJson } from "./atomic-write.js";
|
|
3
4
|
import { cwdHash, stateDir } from "./marker.js";
|
|
4
5
|
/** Bound the per-owner map so a busy multi-session cwd cannot grow it without
|
|
5
6
|
* limit; evicting ALL entries on overflow is crude but rare and self-heals. */
|
|
@@ -34,7 +35,7 @@ export function writeReminder(cwd, obj) {
|
|
|
34
35
|
try {
|
|
35
36
|
// Owner-only perms (see marker.enable()): the state persists session keys.
|
|
36
37
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
37
|
-
|
|
38
|
+
atomicWriteJson(reminderPath(cwd), obj, {
|
|
38
39
|
encoding: "utf8",
|
|
39
40
|
mode: 0o600,
|
|
40
41
|
});
|