@heretyc/subagent-mcp 2.8.0 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/init.js ADDED
@@ -0,0 +1,230 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ const BEGIN_RE = /<!-- subagent-mcp:begin[^>]*-->[\s\S]*?<!-- subagent-mcp:end -->/;
4
+ export const INIT_BLOCK = [
5
+ "<!-- subagent-mcp:begin v1 -->",
6
+ "## subagent-mcp invariant - managed block, do not edit between markers",
7
+ "",
8
+ "BINDING WHILE CONNECTED. While the `subagent-mcp` MCP server is connected",
9
+ "and functioning, follow its MCP `instructions` and injected",
10
+ "`<ORCHESTRATION-INVARIANT>` blocks as binding repo guidance — subordinate to",
11
+ "repo/system safety rules in AGENTS.md, and never a ban on inline-by-right",
12
+ "main-session-only steps or temp scratch-file IPC.",
13
+ "",
14
+ "- ABSOLUTE NO EXCEPTION INVARIANT: SOLE CHANNEL: route every sub-agent launch through subagent-mcp `launch_agent`.",
15
+ " NEVER FOR ANY REASON use harness-native sub-agent tools or shell-spawned sub-agents while",
16
+ " subagent-mcp is available.",
17
+ "- 5-CALL RULE: if a task likely needs >5 tool calls, or >5 have been spent with",
18
+ " work remaining, stop inline grinding and delegate/orchestrate via subagent-mcp.",
19
+ "- ESCAPE HATCH: if subagent-mcp is not connected or not functioning this turn,",
20
+ " this block is dormant; work normally and report the outage.",
21
+ "<!-- subagent-mcp:end -->",
22
+ ].join("\n");
23
+ function detectEol(s) {
24
+ const crlf = s.indexOf("\r\n");
25
+ const lf = s.replace(/\r\n/g, "").indexOf("\n");
26
+ return crlf >= 0 && (lf < 0 || crlf <= lf) ? "\r\n" : "\n";
27
+ }
28
+ function normalizeBlock(eol) {
29
+ return INIT_BLOCK.split("\n").join(eol);
30
+ }
31
+ function insertAfterFirstHeading(body, block, eol) {
32
+ const m = body.match(/^# .*(?:\r?\n|$)/m);
33
+ if (!m || m.index === undefined)
34
+ return `${block}${eol}${eol}${body}`;
35
+ const end = m.index + m[0].length;
36
+ return `${body.slice(0, end)}${eol}${block}${eol}${body.slice(end)}`;
37
+ }
38
+ function collapseBlankRuns(s, eol) {
39
+ return s.replace(new RegExp(`(?:${eol}){3,}`, "g"), `${eol}${eol}`);
40
+ }
41
+ function removeManagedBlock(body, eol) {
42
+ const match = body.match(BEGIN_RE);
43
+ const next = collapseBlankRuns(body.replace(BEGIN_RE, ""), eol);
44
+ if (!match || match.index !== 0)
45
+ return next;
46
+ let trimmed = next;
47
+ let removed = 0;
48
+ while (trimmed.startsWith(eol) && removed < 2) {
49
+ trimmed = trimmed.slice(eol.length);
50
+ removed++;
51
+ }
52
+ return trimmed;
53
+ }
54
+ function backupOnce(file) {
55
+ if (!existsSync(file))
56
+ return;
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
58
+ const backup = `${file}.bak-init-${stamp}`;
59
+ copyFileSync(file, backup);
60
+ }
61
+ function atomicWrite(file, data, force) {
62
+ mkdirSync(dirname(file), { recursive: true });
63
+ if (force && existsSync(file))
64
+ chmodSync(file, 0o666);
65
+ const tmp = `${file}.tmp-${process.pid}`;
66
+ writeFileSync(tmp, data, "utf8");
67
+ renameSync(tmp, file);
68
+ }
69
+ export function upsertInitBlock(file, opts = {}) {
70
+ const exists = existsSync(file);
71
+ const original = exists ? readFileSync(file, "utf8") : "";
72
+ const hadBom = original.charCodeAt(0) === 0xfeff;
73
+ const body = hadBom ? original.slice(1) : original;
74
+ const eol = exists ? detectEol(body) : "\n";
75
+ const block = normalizeBlock(eol);
76
+ let next = body;
77
+ let status;
78
+ if (opts.remove) {
79
+ if (!exists || !BEGIN_RE.test(body)) {
80
+ status = "absent";
81
+ }
82
+ else {
83
+ next = removeManagedBlock(body, eol);
84
+ status = "removed";
85
+ }
86
+ }
87
+ else if (!exists) {
88
+ next = `${block}${eol}`;
89
+ status = "created";
90
+ }
91
+ else if (BEGIN_RE.test(body)) {
92
+ const current = body.match(BEGIN_RE)?.[0] ?? "";
93
+ if (current === block) {
94
+ status = "ok";
95
+ }
96
+ else {
97
+ next = body.replace(BEGIN_RE, block);
98
+ status = "updated";
99
+ }
100
+ }
101
+ else {
102
+ next = insertAfterFirstHeading(body, block, eol);
103
+ status = "added";
104
+ }
105
+ const changed = !["ok", "absent"].includes(status);
106
+ if (changed && !opts.dryRun) {
107
+ const out = (hadBom ? "\ufeff" : "") + (next.endsWith(eol) ? next : next + eol);
108
+ backupOnce(file);
109
+ atomicWrite(file, out, opts.force === true);
110
+ }
111
+ return { file, status, changed };
112
+ }
113
+ function parseArgs(args) {
114
+ const parsed = {
115
+ dryRun: false,
116
+ remove: false,
117
+ force: false,
118
+ copilot: false,
119
+ cursor: false,
120
+ root: process.cwd(),
121
+ files: null,
122
+ };
123
+ const readValue = (args, i, flag) => {
124
+ const value = args[i + 1];
125
+ if (value === undefined || value === "" || value.startsWith("--")) {
126
+ throw new Error(`${flag} requires a value`);
127
+ }
128
+ return value;
129
+ };
130
+ for (let i = 0; i < args.length; i++) {
131
+ const a = args[i];
132
+ if (a === "--dry-run")
133
+ parsed.dryRun = true;
134
+ else if (a === "--remove" || a === "--uninstall")
135
+ parsed.remove = true;
136
+ else if (a === "--force")
137
+ parsed.force = true;
138
+ else if (a === "--copilot")
139
+ parsed.copilot = true;
140
+ else if (a === "--cursor")
141
+ parsed.cursor = true;
142
+ else if (a === "--root") {
143
+ parsed.root = readValue(args, i, a);
144
+ i++;
145
+ }
146
+ else if (a === "--files") {
147
+ parsed.files = readValue(args, i, a).split(",").filter(Boolean);
148
+ i++;
149
+ }
150
+ else
151
+ throw new Error(`unknown init argument: ${a}`);
152
+ }
153
+ if (!parsed.root)
154
+ throw new Error("--root requires a directory");
155
+ return parsed;
156
+ }
157
+ function isSelfRepo(root) {
158
+ try {
159
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
160
+ return pkg.name === "@heretyc/subagent-mcp";
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ function targetFiles(root, opts) {
167
+ const resolveTarget = (f) => {
168
+ const target = isAbsolute(f) ? resolve(f) : resolve(root, f);
169
+ const rel = relative(root, target);
170
+ if (rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) {
171
+ return target;
172
+ }
173
+ throw new Error(`--files target escapes --root: ${f}`);
174
+ };
175
+ if (opts.files)
176
+ return opts.files.map(resolveTarget);
177
+ const rel = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];
178
+ if (opts.copilot)
179
+ rel.push(".github/copilot-instructions.md");
180
+ if (opts.cursor)
181
+ rel.push(".cursor/rules/subagent-mcp.mdc");
182
+ return rel.map(resolveTarget);
183
+ }
184
+ export async function runInit(args = process.argv.slice(3)) {
185
+ let opts;
186
+ try {
187
+ opts = parseArgs(args);
188
+ }
189
+ catch (e) {
190
+ console.error(e instanceof Error ? e.message : String(e));
191
+ return 1;
192
+ }
193
+ const root = resolve(opts.root);
194
+ if (isSelfRepo(root) && !opts.force) {
195
+ console.error("Refusing to run init inside the subagent-mcp source repo without --force.");
196
+ console.error("This repo keeps CLAUDE.md/GEMINI.md as thin redirects; use --root for a consumer repo.");
197
+ return 1;
198
+ }
199
+ let files;
200
+ try {
201
+ files = targetFiles(root, opts);
202
+ }
203
+ catch (e) {
204
+ console.error(e instanceof Error ? e.message : String(e));
205
+ return 1;
206
+ }
207
+ const issues = [];
208
+ const results = [];
209
+ for (const file of files) {
210
+ try {
211
+ const r = upsertInitBlock(file, opts);
212
+ results.push(r);
213
+ console.log(`${r.status.padEnd(7)} ${file}`);
214
+ }
215
+ catch (e) {
216
+ const msg = e instanceof Error ? e.message : String(e);
217
+ issues.push(`${file}: ${msg}`);
218
+ console.error(`failed ${file}: ${msg}`);
219
+ }
220
+ }
221
+ if (opts.dryRun)
222
+ console.log("(dry-run: no files written)");
223
+ if (issues.length > 0) {
224
+ console.error("\nInit completed with issues:");
225
+ for (const i of issues)
226
+ console.error(`- ${i}`);
227
+ return 1;
228
+ }
229
+ return results.length > 0 ? 0 : 1;
230
+ }
@@ -11,8 +11,8 @@ import * as reminder from "./reminder.js";
11
11
  * turn) READS the marker here and decides what to inject. The hook now emits in
12
12
  * BOTH marker states, on a per-prompt counter (reminder.ts): every
13
13
  * REMINDER_PERIOD-th prompt injects the LONG mode-specific
14
- * <ORCHESTRATION-REMINDER-INVARIANT> block, every prompt between injects the
15
- * one-line pointer at it. Marker ON adds the claim machinery: the claim turn
14
+ * <ORCHESTRATION-INVARIANT> block, every prompt between injects the
15
+ * one-line rule carrier. Marker ON adds the claim machinery: the claim turn
16
16
  * (fresh enable or carryover re-claim) emits the FULL directive plus the ON
17
17
  * reminder block and re-baselines the counter. (Supersedes LOCKED DECISION 2's
18
18
  * same-session rel%5 FULL re-emission — owner directive 2026-06-11: steady
@@ -174,10 +174,10 @@ export function classifyClaim(owner_session, baseline_turn, current) {
174
174
  }
175
175
  /**
176
176
  * Per-prompt reminder cadence emission: the LONG block (longFile) on every
177
- * REMINDER_PERIOD-th counted prompt, the one-line pointer between. When the
177
+ * REMINDER_PERIOD-th counted prompt, the one-line rule carrier between. When the
178
178
  * counter could NOT persist, emit the LONG block — fail VISIBLE: a host whose
179
- * temp dir cannot hold the state file would otherwise inject the pointer on
180
- * every prompt at a block that never arrives.
179
+ * temp dir cannot hold the state file would otherwise inject only the compact
180
+ * rule carrier on every prompt and never refresh the LONG block.
181
181
  */
182
182
  function cadenceEmit(env, adapter, longFile, count, persisted) {
183
183
  return !persisted || count % REMINDER_PERIOD === 0
@@ -218,14 +218,14 @@ export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
218
218
  * does not advance).
219
219
  * 2. marker not active for cwd -> OFF cadence: advance the session's counter
220
220
  * (per-owner; a new session starts its own), LONG OFF-variant reminder when
221
- * count % REMINDER_PERIOD === 0, else the one-line pointer.
221
+ * count % REMINDER_PERIOD === 0, else the one-line rule carrier.
222
222
  * 3. marker active: classify the claim from marker state.
223
223
  * 4. FRESH / CARRYOVER -> claimAndEmit (FULL + ON reminder; CARRYOVER notice
224
224
  * prepended once per marker; counter re-baselined). The transcript turn is
225
225
  * read ONLY here — claim turns are the only consumer of the baseline, and
226
226
  * the tail read is too expensive for the per-prompt steady state.
227
227
  * 5. SAME-SESSION -> ON cadence: LONG ON-variant reminder when
228
- * count % REMINDER_PERIOD === 0, else the one-line pointer.
228
+ * count % REMINDER_PERIOD === 0, else the one-line rule carrier.
229
229
  */
230
230
  export function runHook(payload, env, adapter) {
231
231
  try {
@@ -0,0 +1,32 @@
1
+ import { mkdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { stateDir } from "./marker.js";
4
+ export const LIVENESS_TTL_MS = 120_000;
5
+ export const LIVENESS_INTERVAL_MS = 30_000;
6
+ export function alivePath() {
7
+ return join(stateDir, "alive.flag");
8
+ }
9
+ export function touchAlive(now = Date.now()) {
10
+ try {
11
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
12
+ writeFileSync(alivePath(), `${now}\n`, { encoding: "utf8", mode: 0o600 });
13
+ }
14
+ catch {
15
+ // Hooks fail open when liveness cannot be observed.
16
+ }
17
+ }
18
+ export function serverAlive(now = Date.now()) {
19
+ try {
20
+ const s = statSync(alivePath());
21
+ return now - s.mtimeMs <= LIVENESS_TTL_MS;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export function startLivenessHeartbeat() {
28
+ touchAlive();
29
+ const t = setInterval(() => touchAlive(), LIVENESS_INTERVAL_MS);
30
+ t.unref();
31
+ return t;
32
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { cwdHash, stateDir } from "./marker.js";
5
+ import { serverAlive } from "./liveness.js";
6
+ import { sessionKey } from "./hook-core.js";
7
+ export const INLINE_TOOL_LIMIT = 5;
8
+ const NATIVE_SUBAGENT_TOOLS = new Set(["Task", "Agent", "Explore"]);
9
+ const QUESTION_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
10
+ const SUBAGENT_MCP_TOOL_RE = /(^|__)subagent[-_]?mcp(__|$).*(launch_agent|poll_agent|wait|list_agents|send_message|kill_agent)$/i;
11
+ function hash(s) {
12
+ return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
13
+ }
14
+ function owner(payload) {
15
+ return sessionKey(payload) ?? "null";
16
+ }
17
+ function countPath(payload) {
18
+ const cwd = payload.cwd || process.cwd();
19
+ return join(stateDir, `pretool-${cwdHash(cwd)}-${hash(owner(payload))}.json`);
20
+ }
21
+ function readCount(payload) {
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(countPath(payload), "utf8"));
24
+ return typeof parsed.count === "number" && parsed.count >= 0 ? parsed.count : 0;
25
+ }
26
+ catch {
27
+ return 0;
28
+ }
29
+ }
30
+ function writeCount(payload, count) {
31
+ try {
32
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
33
+ writeFileSync(countPath(payload), JSON.stringify({ owner: owner(payload), count, updated_at: Date.now() }), { encoding: "utf8", mode: 0o600 });
34
+ }
35
+ catch {
36
+ // Fail open: counter persistence is advisory enforcement, not host safety.
37
+ }
38
+ }
39
+ export function resetToolCount(payload) {
40
+ try {
41
+ if (existsSync(countPath(payload)))
42
+ unlinkSync(countPath(payload));
43
+ }
44
+ catch {
45
+ // Fail open.
46
+ }
47
+ }
48
+ function decision(permissionDecision, permissionDecisionReason, additionalContext) {
49
+ return {
50
+ hookSpecificOutput: {
51
+ hookEventName: "PreToolUse",
52
+ permissionDecision,
53
+ permissionDecisionReason,
54
+ ...(additionalContext ? { additionalContext } : {}),
55
+ },
56
+ };
57
+ }
58
+ function isSubagentMcpTool(tool) {
59
+ return (SUBAGENT_MCP_TOOL_RE.test(tool) ||
60
+ [
61
+ "launch_agent",
62
+ "poll_agent",
63
+ "wait",
64
+ "list_agents",
65
+ "send_message",
66
+ "kill_agent",
67
+ ].includes(tool));
68
+ }
69
+ function exemptFromCounter(tool) {
70
+ return isSubagentMcpTool(tool) || QUESTION_TOOLS.has(tool);
71
+ }
72
+ export function runClaudePreTool(payload, env, now = Date.now()) {
73
+ try {
74
+ if (env.SUBAGENT_MCP_SUBAGENT === "1")
75
+ return null;
76
+ if (!serverAlive(now))
77
+ return null;
78
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
79
+ if (!tool)
80
+ return null;
81
+ if (NATIVE_SUBAGENT_TOOLS.has(tool)) {
82
+ return decision("deny", "subagent-mcp is alive; harness-native Task/Agent/Explore is not the sanctioned sub-agent channel. Use the subagent-mcp launch_agent MCP tool with the parent-process sentinel as prompt line 1.");
83
+ }
84
+ if (exemptFromCounter(tool))
85
+ return null;
86
+ const next = readCount(payload) + 1;
87
+ writeCount(payload, next);
88
+ if (next <= INLINE_TOOL_LIMIT)
89
+ return null;
90
+ return decision("ask", `5-CALL RULE: this is tool call ${next} for the current user request. If work remains, delegate through subagent-mcp launch_agent; allow only for main-session-only capability or tight verification.`, "5-CALL RULE reached. Route remaining non-main-session work through subagent-mcp launch_agent; inline only for main-session-only capability or tight verification.");
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }