@heretyc/subagent-mcp 2.7.0 → 2.8.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.
@@ -1,17 +1,13 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
- import { classifyClaim, countJsonlType, readDirective, runHook, sessionKey, } from "../orchestration/hook-core.js";
3
+ import { claimAndEmit, classifyClaim, countJsonlType, runHook, sessionKey, } from "../orchestration/hook-core.js";
4
4
  import * as marker from "../orchestration/marker.js";
5
5
  /**
6
6
  * Codex CLI hook entry. Branches on payload.hook_event_name:
7
- * - 'SessionStart' -> if active and not a subagent, emit FULL (covers the
8
- * turn-0 directive before the first UserPromptSubmit).
9
- * - 'UserPromptSubmit' -> the normal %5 runHook cadence.
10
- *
11
- * NOTE: the Python prototype carried inline 'alternating/odd-turn' comments;
12
- * those were STALE. The operative cadence is every 5th relative turn (%5),
13
- * matching INSTALL.md. The stale alternating comments are intentionally NOT
14
- * reproduced here.
7
+ * - 'SessionStart' -> if active and not a subagent, emit FULL + the ON
8
+ * reminder block (covers the turn-0 directive before
9
+ * the first UserPromptSubmit).
10
+ * - 'UserPromptSubmit' -> the normal per-prompt reminder cadence (runHook).
15
11
  *
16
12
  * Compiles to dist/hooks/orchestration-codex.js and is invoked as:
17
13
  * node "<PLUGIN_ROOT>/dist/hooks/orchestration-codex.js"
@@ -56,13 +52,16 @@ export const codexAdapter = {
56
52
  // Count JSONL lines whose parsed object.type === 'turn_context'. Delegates to
57
53
  // the bounded counter (reads at most the trailing window so a huge/
58
54
  // attacker-supplied transcript can't stall the inline host turn). Unreadable
59
- // -> 0 (fail-safe: emits FULL rather than silently suppressing).
55
+ // -> 0 (fail-safe: the claim baseline stamps at 0; cadence is counter-driven
56
+ // and unaffected). Read on claim turns only.
60
57
  currentTurn(transcriptPath) {
61
58
  return countJsonlType(transcriptPath, "turn_context");
62
59
  },
63
60
  fullDirectiveFile: "orchestration-codex.md",
64
61
  offTurnFile: "off-turn-reminder.md",
65
62
  carryoverDirectiveFile: "carryover-codex.md",
63
+ reminderOnFile: "reminder-on.md",
64
+ reminderOffFile: "reminder-off-codex.md",
66
65
  };
67
66
  /**
68
67
  * Codex dispatcher. SessionStart fires once before the first prompt; it covers
@@ -89,19 +88,11 @@ export function runCodexHook(payload, env, adapter = codexAdapter) {
89
88
  const turn = adapter.currentTurn(payload.transcript_path);
90
89
  const m = marker.readMarker(cwd);
91
90
  const kind = classifyClaim(m.owner_session, m.baseline_turn, current);
92
- // Claim/re-claim for this session and baseline at the current turn.
93
- const firstCarryover = kind === "carryover" && !m.carryover_ack;
94
- m.baseline_turn = turn;
95
- m.owner_session = current ?? null;
96
- if (kind === "carryover") {
97
- m.provenance = "carried-over";
98
- m.carryover_ack = true;
99
- }
100
- marker.writeMarker(cwd, m);
101
- return firstCarryover
102
- ? readDirective(env, adapter.carryoverDirectiveFile) +
103
- readDirective(env, adapter.fullDirectiveFile)
104
- : readDirective(env, adapter.fullDirectiveFile);
91
+ // Claim/re-claim + emit via the SHARED claim path (one copy of the
92
+ // semantics FULL + ON reminder, ack-latched CARRYOVER prepend, counter
93
+ // re-baseline). SessionStart claims even on SAME-SESSION (resume) so
94
+ // turn 0 is always covered.
95
+ return claimAndEmit(cwd, current, turn, m, kind, env, adapter);
105
96
  }
106
97
  // UserPromptSubmit (and any other event) -> normal cadence.
107
98
  return runHook(payload, env, adapter);
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { spawn, execSync } from "child_process";
6
- import { unlinkSync, existsSync, realpathSync } from "node:fs";
5
+ import { spawn, spawnSync, execSync } from "child_process";
6
+ import { unlinkSync, existsSync, realpathSync, readFileSync } from "node:fs";
7
7
  import { randomUUID } from "crypto";
8
- import { isAbsolute, basename } from "node:path";
8
+ import { isAbsolute, basename, join, dirname } from "node:path";
9
9
  import { pathToFileURL } from "url";
10
10
  import { buildCommand } from "./effort.js";
11
11
  import { resolveExeFor } from "./platform.js";
@@ -888,15 +888,79 @@ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MOD
888
888
  const isMain = process.argv[1] !== undefined &&
889
889
  import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
890
890
  if (isMain) {
891
- if (process.argv[2] === "setup") {
891
+ // CLI argument guard — runs BEFORE the stdio transport connects. Any argv[2]
892
+ // other than the known commands used to fall through and silently start the
893
+ // MCP server, which blocks forever waiting on stdin (`subagent-mcp --version`
894
+ // hung indefinitely). Only a bare invocation may reach the server below.
895
+ const arg = process.argv[2];
896
+ const usage = [
897
+ "Usage: subagent-mcp [command]",
898
+ "",
899
+ " (no command) start the MCP stdio server (how vendor CLIs run it)",
900
+ " setup [--dry-run] wire Claude Code CLI / Codex CLI (--dry-run: preview only)",
901
+ " doctor check install and wiring health",
902
+ " --update update to the latest release (npm install -g)",
903
+ " --version, -v print the installed version",
904
+ " --help, -h show this help",
905
+ ].join("\n");
906
+ // dist/index.js -> ../package.json (the installed package manifest).
907
+ const readPkg = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
908
+ if (arg === "--version" || arg === "-v") {
909
+ console.log(readPkg().version);
910
+ process.exit(0);
911
+ }
912
+ if (arg === "--help" || arg === "-h") {
913
+ console.log(usage);
914
+ process.exit(0);
915
+ }
916
+ if (arg === "--update") {
917
+ const pkg = readPkg();
918
+ const npmArgs = ["install", "-g", `${pkg.name}@latest`];
919
+ console.log(`subagent-mcp ${pkg.version} -> npm ${npmArgs.join(" ")}`);
920
+ // npm on Windows is npm.cmd; spawning a .cmd without a shell fails
921
+ // (EINVAL on modern Node). Resolve the underlying npm-cli.js and run it
922
+ // with this same node binary: cmd-shim layout first (npm installed into a
923
+ // prefix), then the official Node-for-Windows layout (npm.cmd sitting
924
+ // next to node_modules\npm). POSIX spawns the npm executable directly.
925
+ const { findOnPath, resolveCmdShimNodeScript } = await import("./setup.js");
926
+ const npm = findOnPath("npm") ?? "npm";
927
+ let r;
928
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(npm)) {
929
+ const sibling = join(dirname(npm), "node_modules", "npm", "bin", "npm-cli.js");
930
+ const js = resolveCmdShimNodeScript(npm) ?? (existsSync(sibling) ? sibling : null);
931
+ r = js
932
+ ? spawnSync(process.execPath, [js, ...npmArgs], { stdio: "inherit" })
933
+ : // Last resort: cmd.exe via shell. The arg vector is a fixed literal
934
+ // list (safe charset only), so there is no quoting/injection surface.
935
+ spawnSync("npm", npmArgs, { stdio: "inherit", shell: true });
936
+ }
937
+ else {
938
+ r = spawnSync(npm, npmArgs, { stdio: "inherit" });
939
+ }
940
+ if (r.error) {
941
+ console.error(`update failed to start npm: ${r.error.message}`);
942
+ process.exit(1);
943
+ }
944
+ const code = r.status ?? 1;
945
+ if (code === 0) {
946
+ console.log("Update complete. Restart your CLI sessions so the MCP server picks up the new build.");
947
+ }
948
+ process.exit(code);
949
+ }
950
+ if (arg === "setup") {
892
951
  const { runSetup } = await import("./setup.js");
893
952
  await runSetup();
894
953
  process.exit(0);
895
954
  }
896
- if (process.argv[2] === "doctor") {
955
+ if (arg === "doctor") {
897
956
  const { runDoctor } = await import("./doctor.js");
898
957
  process.exit(await runDoctor());
899
958
  }
959
+ if (arg !== undefined && arg !== "") {
960
+ console.error(`unknown argument: ${arg}`);
961
+ console.error(usage);
962
+ process.exit(1);
963
+ }
900
964
  // ORCHESTRATION MODE PERSISTS across restarts/sessions: the server does NOT
901
965
  // clear the marker on startup. DEFAULT OFF now means ABSENCE of a marker — a
902
966
  // project never enabled stays OFF; a project explicitly enabled persists ON
@@ -3,6 +3,31 @@ import { closeSync, openSync, readFileSync, readSync, statSync, } from "node:fs"
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import * as marker from "./marker.js";
6
+ import * as reminder from "./reminder.js";
7
+ /**
8
+ * Provider-agnostic core of the UserPromptSubmit / SessionStart hook.
9
+ *
10
+ * The MCP tool only ever WRITES the marker. A SEPARATE hook process (one per
11
+ * turn) READS the marker here and decides what to inject. The hook now emits in
12
+ * BOTH marker states, on a per-prompt counter (reminder.ts): every
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
16
+ * (fresh enable or carryover re-claim) emits the FULL directive plus the ON
17
+ * reminder block and re-baselines the counter. (Supersedes LOCKED DECISION 2's
18
+ * same-session rel%5 FULL re-emission — owner directive 2026-06-11: steady
19
+ * state is the leaner tagged reminder, FULL fires on claim turns only.) The
20
+ * marker PERSISTS across sessions/restarts, so the first turn of a new session
21
+ * that inherits an already-ON marker emits a CARRYOVER notice (prepended to
22
+ * FULL) once per project marker, ack-latched in marker state, and re-claims
23
+ * for that session.
24
+ *
25
+ * The entire run is wrapped in try/catch: on ANY error we emit nothing. A hook
26
+ * must never crash or stall the host turn. "Emit" means RETURN the string; the
27
+ * entry shim is what writes it to process.stdout.
28
+ */
29
+ /** Long-reminder cadence: every Nth counted prompt is a LONG turn. */
30
+ export const REMINDER_PERIOD = 5;
6
31
  /**
7
32
  * Resolve the repo-root `directives/` dir at runtime. Honors an explicit plugin
8
33
  * root (Claude sets CLAUDE_PLUGIN_ROOT; a generic PLUGIN_ROOT is also accepted)
@@ -147,20 +172,60 @@ export function classifyClaim(owner_session, baseline_turn, current) {
147
172
  }
148
173
  return "same";
149
174
  }
175
+ /**
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
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.
181
+ */
182
+ function cadenceEmit(env, adapter, longFile, count, persisted) {
183
+ return !persisted || count % REMINDER_PERIOD === 0
184
+ ? readDirective(env, longFile)
185
+ : readDirective(env, adapter.offTurnFile);
186
+ }
187
+ /**
188
+ * Claim (or re-claim) an active marker for the current session and emit the
189
+ * claim-turn payload: FULL directive + ON reminder block, with the CARRYOVER
190
+ * notice prepended on the first foreign-owner claim of a marker (ack-latched,
191
+ * so sub-agent/parallel-session marker ping-pong cannot re-fire it). The
192
+ * reminder counter re-baselines to 0 — the claim turn IS a LONG turn, so the
193
+ * next LONG fires exactly REMINDER_PERIOD prompts later. Shared by runHook's
194
+ * claim branch and the Codex SessionStart dispatcher (one copy of the claim
195
+ * semantics, no drift).
196
+ */
197
+ export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
198
+ const firstCarryover = kind === "carryover" && !m.carryover_ack;
199
+ m.baseline_turn = turn;
200
+ m.owner_session = current ?? null;
201
+ if (kind === "carryover") {
202
+ m.provenance = "carried-over";
203
+ m.carryover_ack = true;
204
+ }
205
+ marker.writeMarker(cwd, m);
206
+ reminder.rebase(cwd, current, 0);
207
+ const full = readDirective(env, adapter.fullDirectiveFile) +
208
+ readDirective(env, adapter.reminderOnFile);
209
+ return firstCarryover
210
+ ? readDirective(env, adapter.carryoverDirectiveFile) + full
211
+ : full;
212
+ }
150
213
  /**
151
214
  * Core hook logic. Returns the string to inject, or '' to inject nothing.
152
215
  *
153
216
  * 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.
217
+ * 1. subagent -> '' (a subagent must never be nagged to delegate; the counter
218
+ * does not advance).
219
+ * 2. marker not active for cwd -> OFF cadence: advance the session's counter
220
+ * (per-owner; a new session starts its own), LONG OFF-variant reminder when
221
+ * count % REMINDER_PERIOD === 0, else the one-line pointer.
222
+ * 3. marker active: classify the claim from marker state.
223
+ * 4. FRESH / CARRYOVER -> claimAndEmit (FULL + ON reminder; CARRYOVER notice
224
+ * prepended once per marker; counter re-baselined). The transcript turn is
225
+ * read ONLY here claim turns are the only consumer of the baseline, and
226
+ * the tail read is too expensive for the per-prompt steady state.
227
+ * 5. SAME-SESSION -> ON cadence: LONG ON-variant reminder when
228
+ * count % REMINDER_PERIOD === 0, else the one-line pointer.
164
229
  */
165
230
  export function runHook(payload, env, adapter) {
166
231
  try {
@@ -168,38 +233,21 @@ export function runHook(payload, env, adapter) {
168
233
  return "";
169
234
  }
170
235
  const cwd = payload.cwd || process.cwd();
236
+ const current = sessionKey(payload);
171
237
  if (!marker.isActive(cwd)) {
172
- return "";
238
+ // OFF: no claim machinery — just the per-prompt reminder cadence.
239
+ const r = reminder.advance(cwd, current);
240
+ return cadenceEmit(env, adapter, adapter.reminderOffFile, r.count, r.persisted);
173
241
  }
174
- const current = sessionKey(payload);
175
- const turn = adapter.currentTurn(payload.transcript_path);
176
242
  const m = marker.readMarker(cwd);
177
243
  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);
244
+ if (kind === "fresh" || kind === "carryover") {
245
+ const turn = adapter.currentTurn(payload.transcript_path);
246
+ return claimAndEmit(cwd, current, turn, m, kind, env, adapter);
198
247
  }
199
- const rel = turn - m.baseline_turn;
200
- return rel % 5 === 0
201
- ? readDirective(env, adapter.fullDirectiveFile)
202
- : readDirective(env, adapter.offTurnFile);
248
+ // SAME-SESSION: per-prompt reminder cadence, ON variant.
249
+ const r = reminder.advance(cwd, current);
250
+ return cadenceEmit(env, adapter, adapter.reminderOnFile, r.count, r.persisted);
203
251
  }
204
252
  catch {
205
253
  // Any failure -> inject nothing. Never crash or stall the host turn.
@@ -3,6 +3,12 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  const markerDir = join(tmpdir(), "subagent-mcp");
6
+ /**
7
+ * Shared per-project state dir for ALL hook state files (marker + reminder
8
+ * counter). Exported so sibling state modules key off the SAME location — a
9
+ * future move edits one constant, not N copies.
10
+ */
11
+ export const stateDir = markerDir;
6
12
  /**
7
13
  * Canonicalize a working directory so two spellings of the same path hash
8
14
  * identically. Strip a leading Windows \\?\ extended-length prefix FIRST (on
@@ -0,0 +1,72 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cwdHash, stateDir } from "./marker.js";
4
+ /** Bound the per-owner map so a busy multi-session cwd cannot grow it without
5
+ * limit; evicting ALL entries on overflow is crude but rare and self-heals. */
6
+ const OWNER_CAP = 8;
7
+ function ownerKey(current) {
8
+ return current ?? "null";
9
+ }
10
+ export function reminderPath(cwd) {
11
+ return join(stateDir, "remind-" + cwdHash(cwd) + ".json");
12
+ }
13
+ export function readReminder(cwd) {
14
+ try {
15
+ const raw = readFileSync(reminderPath(cwd), "utf8");
16
+ const parsed = JSON.parse(raw);
17
+ const counts = {};
18
+ if (parsed.counts && typeof parsed.counts === "object") {
19
+ for (const [owner, count] of Object.entries(parsed.counts)) {
20
+ if (typeof count === "number" && Number.isFinite(count)) {
21
+ counts[owner] = count;
22
+ }
23
+ }
24
+ }
25
+ return { counts };
26
+ }
27
+ catch {
28
+ // Missing/corrupt state -> safe default (no owners counted yet).
29
+ return { counts: {} };
30
+ }
31
+ }
32
+ /** Persist the state. Returns true on success, false on any write failure. */
33
+ export function writeReminder(cwd, obj) {
34
+ try {
35
+ // Owner-only perms (see marker.enable()): the state persists session keys.
36
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
37
+ writeFileSync(reminderPath(cwd), JSON.stringify(obj), {
38
+ encoding: "utf8",
39
+ mode: 0o600,
40
+ });
41
+ return true;
42
+ }
43
+ catch {
44
+ // Fail-safe: report the failure; never throw.
45
+ return false;
46
+ }
47
+ }
48
+ /**
49
+ * Count one user prompt for the current session and persist. Returns the
50
+ * session's advanced count plus whether the state persisted (persisted=false
51
+ * lets the caller fail visible instead of never reaching the LONG cadence).
52
+ */
53
+ export function advance(cwd, current) {
54
+ const owner = ownerKey(current);
55
+ const state = readReminder(cwd);
56
+ if (!(owner in state.counts) && Object.keys(state.counts).length >= OWNER_CAP) {
57
+ state.counts = {};
58
+ }
59
+ const count = (state.counts[owner] ?? 0) + 1;
60
+ state.counts[owner] = count;
61
+ const persisted = writeReminder(cwd, state);
62
+ return { count, persisted };
63
+ }
64
+ /**
65
+ * Re-baseline the current session's count (claim turns set 0: the claim turn
66
+ * IS a LONG turn, so the next LONG fires exactly REMINDER_PERIOD prompts on).
67
+ */
68
+ export function rebase(cwd, current, count) {
69
+ const state = readReminder(cwd);
70
+ state.counts[ownerKey(current)] = count;
71
+ writeReminder(cwd, state);
72
+ }