@heretyc/subagent-mcp 2.7.1 → 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);
@@ -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
+ }