@heretyc/subagent-mcp 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import * as marker from "./marker.js";
6
+ /**
7
+ * Resolve the repo-root `directives/` dir at runtime. Honors an explicit plugin
8
+ * root (Claude sets CLAUDE_PLUGIN_ROOT; a generic PLUGIN_ROOT is also accepted)
9
+ * so the bundled plugin finds its assets wherever it is installed. Otherwise we
10
+ * walk up from the COMPILED file location: dist/hooks/<x>.js -> ../../directives
11
+ * === <repoRoot>/directives.
12
+ */
13
+ export function resolveDirectivesDir(env) {
14
+ const root = env.CLAUDE_PLUGIN_ROOT || env.PLUGIN_ROOT;
15
+ if (root) {
16
+ return join(root, "directives");
17
+ }
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ // Compiled location is dist/orchestration/hook-core.js, so ../../directives
20
+ // is the repo root's directives dir; the entry shims live at dist/hooks/<x>.js
21
+ // and import this module, but __dirname here is the hook-core module's own
22
+ // dir. Two levels up from dist/orchestration is the repo root either way.
23
+ return join(here, "..", "..", "directives");
24
+ }
25
+ /** Read a directive asset by filename. On ANY failure return '' (fail-safe). */
26
+ export function readDirective(env, fileName) {
27
+ try {
28
+ return readFileSync(join(resolveDirectivesDir(env), fileName), "utf8");
29
+ }
30
+ catch {
31
+ return "";
32
+ }
33
+ }
34
+ /**
35
+ * Hard cap on how many bytes of a transcript we will read when counting turns.
36
+ * Transcripts grow without bound over a long session, and the hook runs INLINE
37
+ * on every UserPromptSubmit before the prompt is sent — an unbounded
38
+ * readFileSync(...,'utf8') + full split('\n') is O(file size) per turn (O(n^2)
39
+ * over a session) and a multi-hundred-MB (or attacker-supplied) transcript_path
40
+ * could stall the user's turn for seconds or OOM the hook. We therefore read at
41
+ * most the trailing TRANSCRIPT_READ_CAP bytes.
42
+ */
43
+ export const TRANSCRIPT_READ_CAP = 16 * 1024 * 1024; // 16 MB
44
+ /**
45
+ * Count JSONL lines in a transcript whose parsed object.type === `wantedType`,
46
+ * reading at most the trailing TRANSCRIPT_READ_CAP bytes (the most recent turns
47
+ * are at the end of the file). Fully fail-safe: any error -> 0, which makes the
48
+ * caller emit FULL (a visible directive) rather than silently suppressing.
49
+ *
50
+ * For a tail read we drop the first (likely partial) line of the window so we
51
+ * never mis-parse a line cut in half by the cap boundary. Under-counting by at
52
+ * most one line at the boundary is acceptable for cadence; over a 16 MB window
53
+ * the relative turn count stays stable across consecutive turns.
54
+ */
55
+ export function countJsonlType(transcriptPath, wantedType) {
56
+ if (!transcriptPath)
57
+ return 0;
58
+ let fd;
59
+ try {
60
+ const size = statSync(transcriptPath).size;
61
+ let raw;
62
+ let droppedPartialHead = false;
63
+ if (size <= TRANSCRIPT_READ_CAP) {
64
+ raw = readFileSync(transcriptPath, "utf8");
65
+ }
66
+ else {
67
+ // Read only the trailing window via an fd so we never materialize the
68
+ // whole file. The first line of the window is probably truncated.
69
+ const start = size - TRANSCRIPT_READ_CAP;
70
+ const buf = Buffer.allocUnsafe(TRANSCRIPT_READ_CAP);
71
+ fd = openSync(transcriptPath, "r");
72
+ let offset = 0;
73
+ let pos = start;
74
+ while (offset < TRANSCRIPT_READ_CAP) {
75
+ const bytes = readSync(fd, buf, offset, TRANSCRIPT_READ_CAP - offset, pos);
76
+ if (bytes <= 0)
77
+ break;
78
+ offset += bytes;
79
+ pos += bytes;
80
+ }
81
+ raw = buf.toString("utf8", 0, offset);
82
+ droppedPartialHead = true;
83
+ }
84
+ let count = 0;
85
+ let first = true;
86
+ for (const line of raw.split("\n")) {
87
+ // Drop the first (partial) line only when we read a tail window.
88
+ if (first) {
89
+ first = false;
90
+ if (droppedPartialHead)
91
+ continue;
92
+ }
93
+ const trimmed = line.trim();
94
+ if (!trimmed)
95
+ continue;
96
+ try {
97
+ const obj = JSON.parse(trimmed);
98
+ if (obj && obj.type === wantedType)
99
+ count++;
100
+ }
101
+ catch {
102
+ // Skip unparseable lines; never throw.
103
+ }
104
+ }
105
+ return count;
106
+ }
107
+ catch {
108
+ return 0;
109
+ }
110
+ finally {
111
+ if (fd !== undefined) {
112
+ try {
113
+ closeSync(fd);
114
+ }
115
+ catch {
116
+ // Best-effort close; never throw.
117
+ }
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Return the stable key used to compare hook claims. Some hosts omit
123
+ * session_id; transcript_path is per-session, so a short hash keeps the claim
124
+ * sticky without changing classifyClaim's string/undefined contract.
125
+ */
126
+ export function sessionKey(payload) {
127
+ if (typeof payload.session_id === "string") {
128
+ return payload.session_id;
129
+ }
130
+ if (typeof payload.transcript_path === "string" &&
131
+ payload.transcript_path.length > 0) {
132
+ return ("tp-" +
133
+ createHash("sha256")
134
+ .update(payload.transcript_path, "utf8")
135
+ .digest("hex")
136
+ .slice(0, 16));
137
+ }
138
+ return undefined;
139
+ }
140
+ export function classifyClaim(owner_session, baseline_turn, current) {
141
+ if (baseline_turn == null || owner_session == null) {
142
+ return "fresh";
143
+ }
144
+ // owner_session is a real string here.
145
+ if (current === undefined || owner_session !== current) {
146
+ return "carryover";
147
+ }
148
+ return "same";
149
+ }
150
+ /**
151
+ * Core hook logic. Returns the string to inject, or '' to inject nothing.
152
+ *
153
+ * Order:
154
+ * 1. subagent -> '' (a subagent must never be nagged to delegate).
155
+ * 2. marker not active for cwd -> '' (OFF; zero emission).
156
+ * 3. read current turn + marker state, classify the claim.
157
+ * 4. FRESH (never claimed) -> claim + baseline at this turn, persist, emit FULL
158
+ * (this is the freshly-enabled turn, relTurn 0).
159
+ * 5. CARRYOVER (owned by another/prior session) -> re-claim + re-baseline at
160
+ * this turn, persist, emit the CARRYOVER notice prepended to FULL only
161
+ * before the marker's carryover_ack has latched.
162
+ * 6. SAME-SESSION -> rel = turn - baseline; FULL when rel % 5 === 0, else
163
+ * off-turn.
164
+ */
165
+ export function runHook(payload, env, adapter) {
166
+ try {
167
+ if (adapter.isSubagent(payload, env)) {
168
+ return "";
169
+ }
170
+ const cwd = payload.cwd || process.cwd();
171
+ if (!marker.isActive(cwd)) {
172
+ return "";
173
+ }
174
+ const current = sessionKey(payload);
175
+ const turn = adapter.currentTurn(payload.transcript_path);
176
+ const m = marker.readMarker(cwd);
177
+ const kind = classifyClaim(m.owner_session, m.baseline_turn, current);
178
+ if (kind === "fresh") {
179
+ m.baseline_turn = turn;
180
+ m.owner_session = current ?? null;
181
+ marker.writeMarker(cwd, m);
182
+ return readDirective(env, adapter.fullDirectiveFile);
183
+ }
184
+ if (kind === "carryover") {
185
+ // Re-claim for the current session and re-baseline at this turn so the
186
+ // notice fires once per project marker. The ack survives re-claims, so
187
+ // sub-agent/parallel-session marker ping-pong cannot re-fire it.
188
+ const firstTime = !m.carryover_ack;
189
+ m.baseline_turn = turn;
190
+ m.owner_session = current ?? null;
191
+ m.provenance = "carried-over";
192
+ m.carryover_ack = true;
193
+ marker.writeMarker(cwd, m);
194
+ return firstTime
195
+ ? readDirective(env, adapter.carryoverDirectiveFile) +
196
+ readDirective(env, adapter.fullDirectiveFile)
197
+ : readDirective(env, adapter.fullDirectiveFile);
198
+ }
199
+ const rel = turn - m.baseline_turn;
200
+ return rel % 5 === 0
201
+ ? readDirective(env, adapter.fullDirectiveFile)
202
+ : readDirective(env, adapter.offTurnFile);
203
+ }
204
+ catch {
205
+ // Any failure -> inject nothing. Never crash or stall the host turn.
206
+ return "";
207
+ }
208
+ }
@@ -0,0 +1,139 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ const markerDir = join(tmpdir(), "subagent-mcp");
6
+ /**
7
+ * Canonicalize a working directory so two spellings of the same path hash
8
+ * identically. Strip a leading Windows \\?\ extended-length prefix FIRST (on
9
+ * the raw input) — resolve() canonicalizes that prefix away, so stripping after
10
+ * resolve is dead code and an extended-length cwd would otherwise hash
11
+ * differently from its plain form. Then resolve() to an absolute path; use
12
+ * forward slashes; lowercase on win32 (the FS is case-insensitive there); drop
13
+ * a trailing slash.
14
+ */
15
+ export function normalizeCwd(cwd) {
16
+ let raw = cwd;
17
+ if (raw.startsWith("\\\\?\\")) {
18
+ raw = raw.slice(4);
19
+ }
20
+ let p = resolve(raw);
21
+ p = p.replace(/\\/g, "/");
22
+ if (process.platform === "win32") {
23
+ p = p.toLowerCase();
24
+ }
25
+ if (p.length > 1 && p.endsWith("/")) {
26
+ p = p.slice(0, -1);
27
+ }
28
+ return p;
29
+ }
30
+ export function cwdHash(cwd) {
31
+ return createHash("sha256")
32
+ .update(normalizeCwd(cwd), "utf8")
33
+ .digest("hex")
34
+ .slice(0, 16);
35
+ }
36
+ export function markerPath(cwd) {
37
+ return join(markerDir, "orch-" + cwdHash(cwd) + ".flag");
38
+ }
39
+ /**
40
+ * Enable orchestration for cwd. ALWAYS overwrites — re-enabling re-baselines by
41
+ * clearing owner_session/baseline_turn back to null so the next hook turn
42
+ * re-claims and re-baselines.
43
+ */
44
+ export function enable(cwd) {
45
+ try {
46
+ // Restrictive POSIX perms: the marker dir/file live in the shared,
47
+ // world-readable /tmp on Linux/macOS and persist a session_id. mode 0o700/
48
+ // 0o600 keeps them owner-only so other local users cannot read the
49
+ // session_id or enumerate which projects have orchestration enabled. mode is
50
+ // ignored on Windows (harmless; tmpdir is already per-user there).
51
+ mkdirSync(markerDir, { recursive: true, mode: 0o700 });
52
+ const state = {
53
+ owner_session: null,
54
+ baseline_turn: null,
55
+ provenance: "user-enabled",
56
+ carryover_ack: false,
57
+ };
58
+ writeFileSync(markerPath(cwd), JSON.stringify(state), { encoding: "utf8", mode: 0o600 });
59
+ }
60
+ catch {
61
+ // Fail-safe: never throw to the caller.
62
+ }
63
+ }
64
+ /**
65
+ * Disable orchestration for cwd by removing the marker.
66
+ *
67
+ * No existsSync() guard: that only opens a TOCTOU window where a concurrent
68
+ * clearForCwd/disable for the same cwd removes the file between the check and
69
+ * the unlink. We just unlink and swallow ENOENT (already-gone is success).
70
+ *
71
+ * KNOWN LIMITATION: the marker is keyed by cwd, NOT by session. Two CLI
72
+ * sessions in the same project share one marker, so their enable/disable
73
+ * interleave and the last writer wins. Per-session isolation would require
74
+ * keying the marker by cwd+session_id; not done here because LOCKED DECISION 1
75
+ * keys the marker by working directory alone. (The hook tracks the owning
76
+ * session via owner_session so a carried-over marker is re-claimed, but the
77
+ * marker file itself is still shared per cwd.)
78
+ */
79
+ export function disable(cwd) {
80
+ try {
81
+ unlinkSync(markerPath(cwd));
82
+ }
83
+ catch (e) {
84
+ if (e?.code !== "ENOENT") {
85
+ // Any non-ENOENT failure is still swallowed (fail-safe); ENOENT means the
86
+ // marker was already gone, which is the desired end state anyway.
87
+ }
88
+ }
89
+ }
90
+ export function isActive(cwd) {
91
+ try {
92
+ return existsSync(markerPath(cwd));
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ export function readMarker(cwd) {
99
+ try {
100
+ const raw = readFileSync(markerPath(cwd), "utf8");
101
+ const parsed = JSON.parse(raw);
102
+ const provenance = parsed.provenance === "user-enabled" || parsed.provenance === "carried-over"
103
+ ? parsed.provenance
104
+ : null;
105
+ return {
106
+ owner_session: typeof parsed.owner_session === "string" ? parsed.owner_session : null,
107
+ baseline_turn: typeof parsed.baseline_turn === "number" ? parsed.baseline_turn : null,
108
+ provenance,
109
+ carryover_ack: typeof parsed.carryover_ack === "boolean" ? parsed.carryover_ack : false,
110
+ };
111
+ }
112
+ catch {
113
+ // Missing/corrupt marker -> safe default (unclaimed, no baseline/ack).
114
+ return {
115
+ owner_session: null,
116
+ baseline_turn: null,
117
+ provenance: null,
118
+ carryover_ack: false,
119
+ };
120
+ }
121
+ }
122
+ export function writeMarker(cwd, obj) {
123
+ try {
124
+ // Owner-only perms (see enable()): the marker persists owner_session.
125
+ mkdirSync(markerDir, { recursive: true, mode: 0o700 });
126
+ writeFileSync(markerPath(cwd), JSON.stringify(obj), { encoding: "utf8", mode: 0o600 });
127
+ }
128
+ catch {
129
+ // Fail-safe.
130
+ }
131
+ }
132
+ /**
133
+ * Marker removal alias, identical to disable. RETAINED for callers that clear a
134
+ * marker explicitly (e.g. the tool's enabled:false path). NOTE: the server no
135
+ * longer calls this on startup — orchestration mode now PERSISTS across sessions.
136
+ */
137
+ export function clearForCwd(cwd) {
138
+ disable(cwd);
139
+ }
@@ -0,0 +1,128 @@
1
+ // Defensive extraction of a sub-agent's final assistant turn text from its
2
+ // captured stdout. NEVER throws; on any parse failure, unknown shape, or empty
3
+ // result it falls back to the raw stdout (trimmed). Empty stdout -> "".
4
+ function rawFallback(stdout) {
5
+ return (stdout || "").trim();
6
+ }
7
+ // Pull a final assistant-message string out of one parsed codex `--json` event.
8
+ // Codex emits newline-delimited JSON; the final assistant message has appeared
9
+ // under a few shapes across CLI versions, so match tolerantly.
10
+ function codexEventText(evt) {
11
+ if (!evt || typeof evt !== "object")
12
+ return null;
13
+ const e = evt;
14
+ // Shape A: { type: "agent_message", message: "..." }
15
+ if (e.type === "agent_message" && typeof e.message === "string") {
16
+ return e.message;
17
+ }
18
+ // Shape B: { type: "item.completed", item: { item_type: "agent_message", text: "..." } }
19
+ if (e.type === "item.completed" && e.item && typeof e.item === "object") {
20
+ const item = e.item;
21
+ if (item.item_type === "agent_message" && typeof item.text === "string") {
22
+ return item.text;
23
+ }
24
+ }
25
+ // Shape C: { msg: { type: "agent_message", message: "..." } }
26
+ if (e.msg && typeof e.msg === "object") {
27
+ const msg = e.msg;
28
+ if (msg.type === "agent_message" && typeof msg.message === "string") {
29
+ return msg.message;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ export function extractFinalTurn(provider, stdout) {
35
+ if (!stdout)
36
+ return "";
37
+ if (provider === "claude") {
38
+ try {
39
+ const parsed = JSON.parse(stdout);
40
+ // Object with a string `result` field is claude's final assistant message.
41
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
42
+ const r = parsed.result;
43
+ if (typeof r === "string")
44
+ return r;
45
+ }
46
+ // Array form: last element of type "result" or carrying a string result.
47
+ if (Array.isArray(parsed)) {
48
+ for (let i = parsed.length - 1; i >= 0; i--) {
49
+ const el = parsed[i];
50
+ if (el && typeof el === "object") {
51
+ const obj = el;
52
+ if (obj.type === "result" && typeof obj.result === "string") {
53
+ return obj.result;
54
+ }
55
+ if (typeof obj.result === "string") {
56
+ return obj.result;
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ catch {
63
+ // Not a single buffered object/array — fall through to stream-json scan.
64
+ }
65
+ // stream-json: one JSON event per line. Prefer the final `result` event;
66
+ // otherwise the last assistant `text` block.
67
+ let resultText = null;
68
+ let lastAssistantText = null;
69
+ for (const line of stdout.split("\n")) {
70
+ const trimmed = line.trim();
71
+ if (!trimmed)
72
+ continue;
73
+ let evt;
74
+ try {
75
+ evt = JSON.parse(trimmed);
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ if (!evt || typeof evt !== "object")
81
+ continue;
82
+ const e = evt;
83
+ if (e.type === "result" && typeof e.result === "string") {
84
+ resultText = e.result;
85
+ }
86
+ else if (e.type === "assistant" && e.message && typeof e.message === "object") {
87
+ const content = e.message.content;
88
+ if (Array.isArray(content)) {
89
+ for (const block of content) {
90
+ if (block && typeof block === "object") {
91
+ const b = block;
92
+ if (b.type === "text" && typeof b.text === "string")
93
+ lastAssistantText = b.text;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ if (resultText !== null)
100
+ return resultText;
101
+ if (lastAssistantText !== null)
102
+ return lastAssistantText;
103
+ return rawFallback(stdout);
104
+ }
105
+ if (provider === "codex") {
106
+ let last = null;
107
+ const lines = stdout.split("\n");
108
+ for (const line of lines) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed)
111
+ continue;
112
+ try {
113
+ const evt = JSON.parse(trimmed);
114
+ const text = codexEventText(evt);
115
+ if (text !== null)
116
+ last = text;
117
+ }
118
+ catch {
119
+ // skip non-JSON lines
120
+ }
121
+ }
122
+ if (last !== null)
123
+ return last;
124
+ return rawFallback(stdout);
125
+ }
126
+ // Unknown provider: raw fallback.
127
+ return rawFallback(stdout);
128
+ }
@@ -0,0 +1,59 @@
1
+ import { join, posix } from "path";
2
+ /**
3
+ * Pure, dependency-injected exe resolver — enables unit testing with mocked
4
+ * platform and filesystem without spawning real processes.
5
+ *
6
+ * win32: Resolves the real .exe under the npm global prefix (PowerShell/.cmd
7
+ * shims cannot be spawned directly). Falls back to bare name if the
8
+ * expected path does not exist.
9
+ *
10
+ * darwin / linux: The npm global bin directory contains a real symlink that
11
+ * re-execs the correct vendor binary, so the bare name works fine when
12
+ * the shell's PATH is set up normally. For minimal-PATH environments
13
+ * (non-login shells, some MCP host launchers) we probe candidate
14
+ * absolute paths in order and return the first that exists. If none
15
+ * exist we return the bare name so PATH is the final arbiter.
16
+ */
17
+ export function resolveExeFor(provider, platform, deps) {
18
+ if (platform === "win32") {
19
+ const prefix = deps.npmPrefix();
20
+ if (provider === "claude") {
21
+ const exe = join(prefix, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
22
+ if (deps.existsSync(exe))
23
+ return exe;
24
+ }
25
+ else {
26
+ // codex
27
+ const exe = join(prefix, "node_modules", "@openai", "codex", "node_modules", "@openai", "codex-win32-x64", "vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe");
28
+ if (deps.existsSync(exe))
29
+ return exe;
30
+ }
31
+ // Fall back to bare name — PATH resolver
32
+ return provider;
33
+ }
34
+ // darwin / linux: prefer bare name on PATH, but check known absolute
35
+ // candidate locations for non-login shell environments.
36
+ // Use posix.join for path construction so that unix-style prefixes produce
37
+ // forward-slash paths even when this module is built on Windows.
38
+ const prefix = deps.npmPrefix();
39
+ let candidates;
40
+ if (provider === "claude") {
41
+ candidates = [
42
+ posix.join(prefix, "bin", "claude"),
43
+ "/opt/homebrew/bin/claude",
44
+ "/usr/local/bin/claude",
45
+ ];
46
+ }
47
+ else {
48
+ candidates = [
49
+ posix.join(prefix, "bin", "codex"),
50
+ "/opt/homebrew/bin/codex",
51
+ "/usr/local/bin/codex",
52
+ ];
53
+ }
54
+ for (const candidate of candidates) {
55
+ if (deps.existsSync(candidate))
56
+ return candidate;
57
+ }
58
+ return provider; // bare name — last resort, relies on PATH
59
+ }