@heretyc/subagent-mcp 2.12.12 → 2.12.14

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.
@@ -3,6 +3,10 @@ import { fileURLToPath } from "node:url";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
4
  import * as marker from "./marker.js";
5
5
  import * as reminder from "./reminder.js";
6
+ import * as handoff from "./handoff.js";
7
+ import * as latch from "./latch.js";
8
+ import * as metering from "./metering.js";
9
+ import * as template from "./template.js";
6
10
  import { cullStaleSlots, slotDir, ZOMBIE_FORCE_GRACE_MS, } from "../concurrency.js";
7
11
  import { appendUpdateNotice, readInstalledPackageInfo, } from "./update-check.js";
8
12
  /**
@@ -226,9 +230,119 @@ function claimOwner(m, owner, turn, now) {
226
230
  * rule carrier on every prompt and never refresh the LONG block.
227
231
  */
228
232
  function cadenceEmit(env, adapter, longFile, shortFile, count, persisted) {
229
- return !persisted || count % REMINDER_PERIOD === 0
230
- ? readDirective(env, longFile)
231
- : readDirective(env, shortFile);
233
+ const isLong = !persisted || count % REMINDER_PERIOD === 0;
234
+ return {
235
+ body: bodyFromDirective(readDirective(env, isLong ? longFile : shortFile)),
236
+ kind: isLong ? "reminder" : "carrier",
237
+ isLong,
238
+ };
239
+ }
240
+ function bodyFromDirective(raw) {
241
+ return raw
242
+ .replace(/<subagent-mcp\b[^>]*>/g, "")
243
+ .replace(/<\/subagent-mcp>/g, "")
244
+ .trim();
245
+ }
246
+ function appendReadHandoffForLong(body, cwd, current, isLong) {
247
+ if (!isLong)
248
+ return body;
249
+ const record = handoff.readHandoff(cwd);
250
+ if (record?.read_by_session !== current)
251
+ return body;
252
+ const overflowLine = record.overflow_path
253
+ ? `\nOverflow path: ${record.overflow_path}`
254
+ : "";
255
+ return `${body}\n${record.content}${overflowLine}`;
256
+ }
257
+ function composeInjection(emission, effectiveActive, phase, usedPercentage) {
258
+ // Fail-safe: an unreadable/empty directive body injects NOTHING rather than a
259
+ // hollow tag (preserves the pre-template missing-directive contract).
260
+ if (emission.body.trim() === "")
261
+ return null;
262
+ const tag = template.composeTag({
263
+ state: effectiveActive ? "on" : "off",
264
+ kind: emission.kind,
265
+ phase,
266
+ utilization: usedPercentage === null ? "unknown" : `${Math.round(usedPercentage)}%`,
267
+ });
268
+ const footer = template.composeFooter(usedPercentage === null ? null : Math.round(100 - usedPercentage));
269
+ return `${tag}\n${emission.body}\n</subagent-mcp>${footer ? `\n${footer}` : ""}`;
270
+ }
271
+ function composeHookUpdateNotice(env, updateNoticeSessionId, emission, effectiveActive, phase, usedPercentage) {
272
+ const injected = composeInjection(emission, effectiveActive, phase, usedPercentage);
273
+ if (injected === null)
274
+ return "";
275
+ return appendHookUpdateNotice(injected, updateNoticeSessionId, env);
276
+ }
277
+ function readMeteringState(current) {
278
+ const record = metering.readMetering(current);
279
+ const usedPercentage = record?.used_percentage ?? null;
280
+ return {
281
+ usedPercentage,
282
+ phase: metering.phaseFor(usedPercentage),
283
+ };
284
+ }
285
+ /**
286
+ * Lift this turn's usage, persist the metering record, and report whether
287
+ * metering is undetectable (fail-safe ON) for THIS turn.
288
+ *
289
+ * One-turn lag (accepted by design, see context-metering.md): the transcript
290
+ * only carries the PRIOR assistant turn's usage, so the metering data reflects
291
+ * the last COMPLETED turn, not the in-flight one. Thresholds therefore trip one
292
+ * turn late, which is harmless. Turn <= 1 has no completed turn yet, so it is a
293
+ * grace window: no metering is expected and the session is NOT fail-safed.
294
+ */
295
+ function updateMeteringForTurn(payload, env, adapter, current, turnIndex) {
296
+ if (turnIndex <= 1)
297
+ return false;
298
+ const lifted = adapter.liftUsage(payload, env, payload.transcript_path);
299
+ if (lifted === null)
300
+ return true;
301
+ // A valid harness-reported percentage stands on its own: it does not require
302
+ // the static model->window map, so an unknown model is NOT undetectable when a
303
+ // percentage was supplied. Only fall back to requiring window resolution when
304
+ // no harness percentage is available.
305
+ const hasHarnessPercentage = typeof lifted.harnessPercentage === "number" &&
306
+ Number.isFinite(lifted.harnessPercentage);
307
+ if (!hasHarnessPercentage &&
308
+ metering.resolveContextWindow(lifted.harness, lifted.model) === null) {
309
+ return true;
310
+ }
311
+ const record = metering.buildMeteringRecord({
312
+ session_id: current,
313
+ harness: lifted.harness,
314
+ model: lifted.model,
315
+ source_ref: lifted.source_ref,
316
+ usage: lifted.usage,
317
+ event: typeof payload.hook_event_name === "string"
318
+ ? payload.hook_event_name
319
+ : "UserPromptSubmit",
320
+ harnessPercentage: lifted.harnessPercentage,
321
+ });
322
+ metering.writeMetering(current, record);
323
+ return false;
324
+ }
325
+ function providerDirectiveFile(adapter, prefix) {
326
+ return `${prefix}-${adapter.anonScope}.md`;
327
+ }
328
+ /**
329
+ * Single source of truth for the effective ON/OFF decision. Shared by runHook,
330
+ * the Codex SessionStart dispatcher, and the orchestration-mode MCP tool so all
331
+ * three agree on the same turn (no drift between the hook tag and the tool).
332
+ *
333
+ * An explicit session disable always wins (2h TTL, user-only). Otherwise the
334
+ * session is ON when the marker is active, the 15% latch has tripped, or
335
+ * metering is undetectable (fail-safe ON). `meteringUndetectableFailSafe` is
336
+ * supplied by the caller because only the caller knows its own turn context: the
337
+ * hook honors the turn-1 grace window (no completed turn yet, so early turns are
338
+ * NOT fail-safed), and the tool derives it from the persisted metering record.
339
+ */
340
+ export function computeEffectiveActive(cwd, current, now, meteringUndetectableFailSafe) {
341
+ if (current !== undefined && marker.isSessionDisabled(current, now)) {
342
+ return false;
343
+ }
344
+ const latched = current !== undefined && latch.isLatchActive(current, now);
345
+ return marker.isActive(cwd, current) || latched || meteringUndetectableFailSafe;
232
346
  }
233
347
  /**
234
348
  * Claim (or re-claim) an active marker for the current session and emit the
@@ -240,7 +354,7 @@ function cadenceEmit(env, adapter, longFile, shortFile, count, persisted) {
240
354
  * claim branch and the Codex SessionStart dispatcher (one copy of the claim
241
355
  * semantics, no drift).
242
356
  */
243
- export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
357
+ export function claimAndEmit(cwd, current, turn, m, kind, env, adapter, effectiveActive = true, phase = "normal", usedPercentage = null, updateNoticeSessionId, fullBodyFile) {
244
358
  const firstCarryover = kind === "carryover" && !m.carryover_ack;
245
359
  claimOwner(m, current, turn, Date.now());
246
360
  if (kind === "carryover") {
@@ -249,11 +363,29 @@ export function claimAndEmit(cwd, current, turn, m, kind, env, adapter) {
249
363
  }
250
364
  marker.writeMarker(cwd, m);
251
365
  reminder.rebase(cwd, current, 0);
252
- const full = readDirective(env, adapter.fullDirectiveFile) +
253
- readDirective(env, adapter.reminderOnFile);
254
- return firstCarryover
255
- ? readDirective(env, adapter.carryoverDirectiveFile) + full
256
- : full;
366
+ const full = fullBodyFile
367
+ ? bodyFromDirective(readDirective(env, fullBodyFile))
368
+ : bodyFromDirective(readDirective(env, adapter.fullDirectiveFile)) +
369
+ "\n" +
370
+ bodyFromDirective(readDirective(env, adapter.reminderOnFile));
371
+ const handoffBody = phase === "handoff"
372
+ ? "\n" +
373
+ bodyFromDirective(readDirective(env, providerDirectiveFile(adapter, "handoff")))
374
+ : "";
375
+ // The CARRYOVER notice must be emitted on the SAME turn that burns
376
+ // carryover_ack (set above), even when a FULL-body override (e.g. the
377
+ // just-tripped latch coaching) also fires this turn. Prepend it ahead of that
378
+ // body rather than dropping it, or the once-per-marker notice is lost.
379
+ const emission = {
380
+ body: (firstCarryover
381
+ ? bodyFromDirective(readDirective(env, adapter.carryoverDirectiveFile)) +
382
+ "\n" +
383
+ full
384
+ : full) + handoffBody,
385
+ kind: firstCarryover ? "carryover" : "directive",
386
+ isLong: true,
387
+ };
388
+ return composeHookUpdateNotice(env, updateNoticeSessionId, emission, effectiveActive, phase, usedPercentage);
257
389
  }
258
390
  function hookCullDeps(env = process.env) {
259
391
  return {
@@ -309,20 +441,56 @@ export function runHook(payload, env, adapter) {
309
441
  const current = ownerKey(payload, cwd, adapter);
310
442
  const updateNoticeSessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
311
443
  marker.writeCurrentSession(cwd, current);
312
- if (!marker.isActive(cwd, current)) {
313
- // OFF: no claim machinery — just the per-prompt reminder cadence.
444
+ const now = Date.now();
445
+ const turnIndex = adapter.currentTurn(payload.transcript_path);
446
+ const meteringUndetectableFailSafe = updateMeteringForTurn(payload, env, adapter, current, turnIndex);
447
+ const meteringState = readMeteringState(current);
448
+ const wasLatched = latch.isLatchActive(current, now);
449
+ if (meteringState.phase !== "normal" || wasLatched) {
450
+ latch.tripLatch(current, now);
451
+ }
452
+ const isLatched = latch.isLatchActive(current, now);
453
+ const justTrippedLatch = !wasLatched && isLatched;
454
+ const effectiveActive = computeEffectiveActive(cwd, current, now, meteringUndetectableFailSafe);
455
+ if (!effectiveActive) {
314
456
  const r = reminder.advance(cwd, current);
315
- return appendHookUpdateNotice(cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted), updateNoticeSessionId, env);
457
+ // A session that has already read a handoff re-appends the saved content
458
+ // to EVERY LONG reminder (spec), regardless of ON/OFF cadence.
459
+ const offEmission = cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted);
460
+ return composeHookUpdateNotice(env, updateNoticeSessionId, {
461
+ ...offEmission,
462
+ body: appendReadHandoffForLong(offEmission.body, cwd, current, offEmission.isLong),
463
+ }, effectiveActive, meteringState.phase, meteringState.usedPercentage);
316
464
  }
317
465
  const m = marker.readMarker(cwd);
318
466
  const kind = classifyOwnerClaim(m, current);
319
467
  if (kind === "fresh" || kind === "carryover") {
320
- const turn = adapter.currentTurn(payload.transcript_path);
321
- return appendHookUpdateNotice(claimAndEmit(cwd, current, turn, m, kind, env, adapter), updateNoticeSessionId, env);
468
+ return claimAndEmit(cwd, current, turnIndex, m, kind, env, adapter, effectiveActive, meteringState.phase, meteringState.usedPercentage, updateNoticeSessionId, meteringState.phase === "plan" && justTrippedLatch
469
+ ? providerDirectiveFile(adapter, "latch")
470
+ : undefined);
322
471
  }
323
- // SAME-SESSION: per-prompt reminder cadence, ON variant.
324
472
  const r = reminder.advance(cwd, current);
325
- return appendHookUpdateNotice(cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted), updateNoticeSessionId, env);
473
+ let emission = cadenceEmit(env, adapter, adapter.reminderOnFile, adapter.shortOnFile, r.count, r.persisted);
474
+ emission = {
475
+ ...emission,
476
+ body: appendReadHandoffForLong(emission.body, cwd, current, emission.isLong),
477
+ };
478
+ if (meteringState.phase === "plan" && justTrippedLatch) {
479
+ emission = {
480
+ body: bodyFromDirective(readDirective(env, providerDirectiveFile(adapter, "latch"))),
481
+ kind: "directive",
482
+ isLong: true,
483
+ };
484
+ }
485
+ if (meteringState.phase === "handoff") {
486
+ emission = {
487
+ ...emission,
488
+ body: emission.body +
489
+ "\n" +
490
+ bodyFromDirective(readDirective(env, providerDirectiveFile(adapter, "handoff"))),
491
+ };
492
+ }
493
+ return composeHookUpdateNotice(env, updateNoticeSessionId, emission, effectiveActive, meteringState.phase, meteringState.usedPercentage);
326
494
  }
327
495
  catch {
328
496
  // Any failure -> inject nothing. Never crash or stall the host turn.
@@ -0,0 +1,47 @@
1
+ import { mkdirSync, readFileSync, unlinkSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteJson } from "./atomic-write.js";
4
+ import { hashKey, stateDir } from "./marker.js";
5
+ export function latchPath(sessionKey) {
6
+ return join(stateDir, `latch-${hashKey(sessionKey)}.json`);
7
+ }
8
+ export function isLatchActive(sessionKey, now) {
9
+ void now;
10
+ try {
11
+ const raw = readFileSync(latchPath(sessionKey), "utf8");
12
+ const parsed = JSON.parse(raw);
13
+ return (parsed.latched === true &&
14
+ typeof parsed.latched_at === "number" &&
15
+ Number.isFinite(parsed.latched_at) &&
16
+ typeof parsed.session_id === "string");
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export function tripLatch(sessionKey, now) {
23
+ if (isLatchActive(sessionKey, now))
24
+ return;
25
+ try {
26
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
27
+ atomicWriteJson(latchPath(sessionKey), {
28
+ latched: true,
29
+ latched_at: now,
30
+ session_id: sessionKey,
31
+ }, {
32
+ encoding: "utf8",
33
+ mode: 0o600,
34
+ });
35
+ }
36
+ catch {
37
+ // Fail-safe: latch write failures must not crash hook execution.
38
+ }
39
+ }
40
+ export function clearLatch(sessionKey) {
41
+ try {
42
+ unlinkSync(latchPath(sessionKey));
43
+ }
44
+ catch {
45
+ // Fail-safe: callers use this for teardown/admin reset only.
46
+ }
47
+ }
@@ -57,6 +57,9 @@ export function markerPath(cwd) {
57
57
  export function disablePath(sessionKey) {
58
58
  return join(stateDir, `orch-disable-${hashKey(sessionKey)}.json`);
59
59
  }
60
+ export function enablePath(sessionKey) {
61
+ return join(stateDir, `orch-enable-${hashKey(sessionKey)}.json`);
62
+ }
60
63
  function cwdDisablePath(cwd) {
61
64
  return join(stateDir, `orch-disable-${cwdHash(cwd)}.json`);
62
65
  }
@@ -123,6 +126,28 @@ export function removeDisable(sessionKey) {
123
126
  // Fail-safe: never throw to the caller.
124
127
  }
125
128
  }
129
+ export function writeEnable(sessionKey) {
130
+ if (!isSessionScopedKey(sessionKey))
131
+ return;
132
+ try {
133
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
134
+ atomicWriteJson(enablePath(sessionKey), { enabled_at: Date.now() }, {
135
+ encoding: "utf8",
136
+ mode: 0o600,
137
+ });
138
+ }
139
+ catch {
140
+ // Fail-safe: never throw to the caller.
141
+ }
142
+ }
143
+ export function removeEnable(sessionKey) {
144
+ try {
145
+ unlinkSync(enablePath(sessionKey));
146
+ }
147
+ catch {
148
+ // Fail-safe: never throw to the caller.
149
+ }
150
+ }
126
151
  export function writeCurrentSession(cwd, sessionKey, serverKey = process.ppid) {
127
152
  try {
128
153
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -176,11 +201,40 @@ function isDisableActive(path, now) {
176
201
  unlinkSync(path);
177
202
  return false;
178
203
  }
204
+ function isEnableActive(path, now) {
205
+ if (!existsSync(path)) {
206
+ return false;
207
+ }
208
+ const raw = readFileSync(path, "utf8");
209
+ const parsed = JSON.parse(raw);
210
+ if (typeof parsed.enabled_at !== "number") {
211
+ return false;
212
+ }
213
+ if (now - parsed.enabled_at <= ORCH_DISABLE_TTL_MS) {
214
+ return true;
215
+ }
216
+ // Lazy GC side-effect: the enable has expired, so remove it.
217
+ unlinkSync(path);
218
+ return false;
219
+ }
220
+ export function isSessionDisabled(sessionKey, now = Date.now()) {
221
+ try {
222
+ if (!isSessionScopedKey(sessionKey))
223
+ return false;
224
+ return isDisableActive(disablePath(sessionKey), now);
225
+ }
226
+ catch {
227
+ return false;
228
+ }
229
+ }
179
230
  export function isActive(cwd, sessionKey) {
180
231
  try {
181
232
  if (sessionKey === undefined || !isSessionScopedKey(sessionKey))
182
233
  return true;
183
- return !isDisableActive(disablePath(sessionKey), Date.now());
234
+ const now = Date.now();
235
+ if (isDisableActive(disablePath(sessionKey), now))
236
+ return false;
237
+ return isEnableActive(enablePath(sessionKey), now);
184
238
  }
185
239
  catch {
186
240
  return true;
@@ -0,0 +1,158 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteJson } from "./atomic-write.js";
4
+ import { hashKey, ORCH_DISABLE_TTL_MS, stateDir, } from "./marker.js";
5
+ export const PLAN_LATCH_THRESHOLD_PCT = 15;
6
+ export const HANDOFF_UNLOCK_THRESHOLD_PCT = 50;
7
+ export const DEFAULT_CONTEXT_WINDOW = 200000;
8
+ export const LONG_CONTEXT_WINDOW = 1000000;
9
+ export const CODEX_KNOWN_MODEL_IDS = [
10
+ "gpt-5",
11
+ "gpt-5-codex",
12
+ "gpt-5.5",
13
+ "o3",
14
+ "o3-mini",
15
+ "o4-mini",
16
+ ];
17
+ export const CODEX_CONTEXT_WINDOW_BY_MODEL_ID = {
18
+ "gpt-5": DEFAULT_CONTEXT_WINDOW,
19
+ "gpt-5-codex": DEFAULT_CONTEXT_WINDOW,
20
+ "gpt-5.5": DEFAULT_CONTEXT_WINDOW,
21
+ o3: DEFAULT_CONTEXT_WINDOW,
22
+ "o3-mini": DEFAULT_CONTEXT_WINDOW,
23
+ "o4-mini": DEFAULT_CONTEXT_WINDOW,
24
+ };
25
+ export const CLAUDE_CONTEXT_WINDOW_BY_KIND = {
26
+ default: DEFAULT_CONTEXT_WINDOW,
27
+ long: LONG_CONTEXT_WINDOW,
28
+ };
29
+ const EMPTY_USAGE = {
30
+ input: 0,
31
+ output: 0,
32
+ cache_creation: 0,
33
+ cache_read: 0,
34
+ };
35
+ function finiteNumber(value) {
36
+ return typeof value === "number" && Number.isFinite(value);
37
+ }
38
+ function normalizeUsage(usage) {
39
+ if (usage === null || usage === undefined) {
40
+ return { usage: { ...EMPTY_USAGE }, used_tokens: null };
41
+ }
42
+ const normalized = {
43
+ input: finiteNumber(usage.input) ? usage.input : 0,
44
+ output: finiteNumber(usage.output) ? usage.output : 0,
45
+ cache_creation: finiteNumber(usage.cache_creation) ? usage.cache_creation : 0,
46
+ cache_read: finiteNumber(usage.cache_read) ? usage.cache_read : 0,
47
+ };
48
+ return {
49
+ usage: normalized,
50
+ used_tokens: normalized.input +
51
+ normalized.output +
52
+ normalized.cache_creation +
53
+ normalized.cache_read,
54
+ };
55
+ }
56
+ export function resolveContextWindow(harness, modelId) {
57
+ if (!modelId)
58
+ return null;
59
+ if (harness === "claude") {
60
+ if (!/^claude-/i.test(modelId))
61
+ return null;
62
+ if (/\[1m\]/i.test(modelId))
63
+ return LONG_CONTEXT_WINDOW;
64
+ return DEFAULT_CONTEXT_WINDOW;
65
+ }
66
+ if (harness === "codex") {
67
+ if (!CODEX_KNOWN_MODEL_IDS.includes(modelId)) {
68
+ return null;
69
+ }
70
+ if (/-1m\b|\[1m\]/i.test(modelId))
71
+ return LONG_CONTEXT_WINDOW;
72
+ return CODEX_CONTEXT_WINDOW_BY_MODEL_ID[modelId];
73
+ }
74
+ return null;
75
+ }
76
+ export function meteringPath(sessionKey, stateDirOverride = stateDir) {
77
+ return join(stateDirOverride, "ctx-" + hashKey(sessionKey) + ".json");
78
+ }
79
+ export function computeUsedPercentage(record) {
80
+ if (finiteNumber(record.harnessPercentage)) {
81
+ // Clamp to [0,100] like the computed path: a harness could report a
82
+ // transiently out-of-range percentage, and phase/footer math assumes 0-100.
83
+ return Math.min(100, Math.max(0, record.harnessPercentage));
84
+ }
85
+ if (record.used_tokens === null || record.context_window_size === null) {
86
+ return null;
87
+ }
88
+ return Math.min(100, (record.used_tokens / record.context_window_size) * 100);
89
+ }
90
+ export function phaseFor(usedPercentage) {
91
+ return usedPercentage === null
92
+ ? "normal"
93
+ : usedPercentage >= HANDOFF_UNLOCK_THRESHOLD_PCT
94
+ ? "handoff"
95
+ : usedPercentage >= PLAN_LATCH_THRESHOLD_PCT
96
+ ? "plan"
97
+ : "normal";
98
+ }
99
+ export function buildMeteringRecord(input) {
100
+ const context_window_size = resolveContextWindow(input.harness, input.model);
101
+ const normalized = normalizeUsage(input.usage);
102
+ const used_percentage = computeUsedPercentage({
103
+ context_window_size,
104
+ used_tokens: normalized.used_tokens,
105
+ harnessPercentage: input.harnessPercentage,
106
+ });
107
+ return {
108
+ session_id: input.session_id,
109
+ harness: input.harness,
110
+ model: input.model,
111
+ source_ref: input.source_ref,
112
+ context_window_size,
113
+ usage: normalized.usage,
114
+ used_tokens: normalized.used_tokens,
115
+ used_percentage,
116
+ near_limit: used_percentage !== null &&
117
+ used_percentage >= HANDOFF_UNLOCK_THRESHOLD_PCT,
118
+ event: input.event,
119
+ updated_at: Date.now(),
120
+ };
121
+ }
122
+ function isMeteringRecord(value) {
123
+ if (!value || typeof value !== "object")
124
+ return false;
125
+ const record = value;
126
+ return typeof record.updated_at === "number";
127
+ }
128
+ export function readMetering(sessionKey, stateDirOverride = stateDir) {
129
+ try {
130
+ const path = meteringPath(sessionKey, stateDirOverride);
131
+ if (!existsSync(path))
132
+ return null;
133
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
134
+ if (!isMeteringRecord(parsed))
135
+ return null;
136
+ if (Date.now() - parsed.updated_at > ORCH_DISABLE_TTL_MS) {
137
+ unlinkSync(path);
138
+ return null;
139
+ }
140
+ return parsed;
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ export function writeMetering(sessionKey, record, stateDirOverride = stateDir) {
147
+ try {
148
+ mkdirSync(stateDirOverride, { recursive: true, mode: 0o700 });
149
+ atomicWriteJson(meteringPath(sessionKey, stateDirOverride), record, {
150
+ encoding: "utf8",
151
+ mode: 0o600,
152
+ });
153
+ return true;
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
@@ -16,9 +16,9 @@ function decision(permissionDecision, permissionDecisionReason, additionalContex
16
16
  * deny harness-native Task/Agent/Explore while subagent-mcp is alive so all
17
17
  * sub-agent launches route through launch_agent. There is NO inline tool-call
18
18
  * counter — the old inline tool-call-count injection is gone (D11/D24).
19
- * Long-horizon
20
- * upgrades are now agent-self-driven via the OFF-mode cumulative footprint
21
- * check (no hook-side counting).
19
+ * Long-horizon upgrades are now driven by provider-metered context tracking
20
+ * (see docs/spec/dev-loop/orchestration-directive-architecture/context-metering.md),
21
+ * not any hook-side footprint counting.
22
22
  */
23
23
  export function runClaudePreTool(payload, env, now = Date.now()) {
24
24
  try {
@@ -0,0 +1,31 @@
1
+ export const TAG_TEMPLATE = '<subagent-mcp state="{{state}}" kind="{{kind}}" phase="{{phase}}" utilization="{{utilization}}">';
2
+ export const FOOTER_TEMPLATE = "Remaining Context={{remaining}}%";
3
+ const PLACEHOLDER = /\{\{([a-zA-Z0-9_]+)\}\}/g;
4
+ const UNRESOLVED_PLACEHOLDER = /\{\{[a-zA-Z0-9_]+\}\}/;
5
+ export function renderTemplate(template, vars) {
6
+ const rendered = template.replace(PLACEHOLDER, (token, name) => {
7
+ if (!Object.prototype.hasOwnProperty.call(vars, name)) {
8
+ throw new Error(`Missing template variable: ${name}`);
9
+ }
10
+ return vars[name] ?? "";
11
+ });
12
+ if (UNRESOLVED_PLACEHOLDER.test(rendered)) {
13
+ throw new Error("Rendered template contains unresolved placeholder");
14
+ }
15
+ return rendered;
16
+ }
17
+ export function composeTag(vars) {
18
+ // Test-only seam: SUBAGENT_MCP_TEST_TAG_TEMPLATE overrides the tag template so a malformed value forces a throw (mission item 5 fail-safe: any template error => inject nothing). Unset in production.
19
+ const template = process.env.SUBAGENT_MCP_TEST_TAG_TEMPLATE ?? TAG_TEMPLATE;
20
+ return renderTemplate(template, vars);
21
+ }
22
+ export function composeFooter(remainingPct) {
23
+ if (remainingPct === null)
24
+ return "";
25
+ if (!Number.isFinite(remainingPct)) {
26
+ throw new Error("Invalid remaining context percentage");
27
+ }
28
+ return renderTemplate(FOOTER_TEMPLATE, {
29
+ remaining: String(Math.round(remainingPct)),
30
+ });
31
+ }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  const PARK_TIMEOUT_MS = 5 * 60 * 1000;
3
3
  const PER_AGENT_FIFO_CAP = 16;
4
+ const PENDING_PERMISSION_HISTORY_CAP = 200;
4
5
  function publicRecord(record) {
5
6
  // record may be a StoredPendingPermission at runtime, which carries a live
6
7
  // NodeJS.Timeout and a resolve closure. Both are non-serializable (the timer
@@ -57,7 +58,7 @@ export class PendingPermissionManager {
57
58
  answer_reason: "pending-queue cap reached",
58
59
  };
59
60
  this.telemetry.cap_overflow_auto_denies += 1;
60
- this.history.push(publicRecord(record));
61
+ this.remember(record);
61
62
  console.error(`[permissions] auto-deny ${record.request_id} for agent ${record.agent_id}: pending-queue cap reached`);
62
63
  void Promise.resolve(input.resolve({
63
64
  request_id: record.request_id,
@@ -125,6 +126,7 @@ export class PendingPermissionManager {
125
126
  if (record)
126
127
  closed.push(await this.finish(record, "deny", reason));
127
128
  }
129
+ this.askedCountByAgent.delete(agentId);
128
130
  return closed;
129
131
  }
130
132
  async autoDeny(requestId, reason) {
@@ -141,8 +143,10 @@ export class PendingPermissionManager {
141
143
  const queue = (this.pendingByAgent.get(record.agent_id) ?? []).filter((id) => id !== record.request_id);
142
144
  if (queue.length > 0)
143
145
  this.pendingByAgent.set(record.agent_id, queue);
144
- else
146
+ else {
145
147
  this.pendingByAgent.delete(record.agent_id);
148
+ this.askedCountByAgent.delete(record.agent_id);
149
+ }
146
150
  record.state = autoRule ? "auto_answered" : "answered";
147
151
  record.auto_answer_rule = autoRule;
148
152
  record.answered_at = Date.now();
@@ -160,10 +164,16 @@ export class PendingPermissionManager {
160
164
  record.state = "errored";
161
165
  record.answer_reason = e instanceof Error ? e.message : String(e);
162
166
  }
163
- this.history.push(publicRecord(record));
167
+ this.remember(record);
164
168
  this.emitQueue(record.agent_id);
165
169
  return publicRecord(record);
166
170
  }
171
+ remember(record) {
172
+ this.history.push(publicRecord(record));
173
+ if (this.history.length > PENDING_PERMISSION_HISTORY_CAP) {
174
+ this.history.splice(0, this.history.length - PENDING_PERMISSION_HISTORY_CAP);
175
+ }
176
+ }
167
177
  emitQueue(agentId) {
168
178
  const count = this.pendingCount(agentId);
169
179
  for (const listener of this.queueListeners)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.12.12",
3
+ "version": "2.12.14",
4
4
  "description": "MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions (no direct Anthropic/OpenAI API).",
5
5
  "keywords": [
6
6
  "mcp",
@@ -39,7 +39,7 @@
39
39
  "postinstall": "node scripts/postinstall.mjs",
40
40
  "prepare": "npm run build",
41
41
  "prepublishOnly": "npm test",
42
- "test": "npm run check:versions && npm run check:prose && node test/effort.test.mjs && node test/drivers.test.mjs && node test/platform.test.mjs && node test/wait.test.mjs && node test/status.test.mjs && node test/output.test.mjs && node test/stream.test.mjs && node test/routing.test.mjs && node test/deadlock.test.mjs && node test/handler-validation.test.mjs && node test/index-handler.test.mjs && node test/ruleset.test.mjs && node test/ruleset-exec.test.mjs && node test/ruleset-handler.test.mjs && node test/failover.test.mjs && node test/global-concurrency-cap.test.mjs && node test/update-check.test.mjs && node test/atomic-write.test.mjs && node test/zombie.test.mjs && node test/zombie-reap-idle.test.mjs && node test/orchestration-marker.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-pretool.test.mjs && node test/orchestration-directives.test.mjs && node test/no-five-call.test.mjs && node test/no-per-provider-cap.test.mjs && node test/no-api-keys.test.mjs && node test/rag-pointers.test.mjs && node test/check-worktree-subagent.test.mjs && node test/launch-agent-upsert.test.mjs && node test/claude-session-limit.test.mjs && node test/init-migration.test.mjs && node test/init-global.test.mjs && node test/mirror-fragments.test.mjs && node test/performance-tier-effort.test.mjs && node test/setup-repair.test.mjs && node test/setup-quoting.test.mjs && node test/setup-wire.test.mjs && node test/setup-cli-integration.test.mjs && node test/init.test.mjs && node test/model-selection-mode.test.mjs && node test/cli-args.test.mjs && node test/index-guard.test.mjs && node test/drivers-guard.test.mjs && node test/audit-next13-lb3.test.mjs && node test/zombie-guard.test.mjs && node test/concurrency-guard.test.mjs && node test/hook-core-guard.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/config-bom.test.mjs && node test/permission-system.test.mjs && node test/lifecycle-matrix.test.mjs && node test/output-hook-registration.test.mjs && node test/mcp-compliance.test.mjs"
42
+ "test": "npm run check:versions && npm run check:prose && node test/effort.test.mjs && node test/drivers.test.mjs && node test/platform.test.mjs && node test/wait.test.mjs && node test/status.test.mjs && node test/output.test.mjs && node test/stream.test.mjs && node test/routing.test.mjs && node test/deadlock.test.mjs && node test/handler-validation.test.mjs && node test/index-handler.test.mjs && node test/ruleset.test.mjs && node test/ruleset-exec.test.mjs && node test/ruleset-handler.test.mjs && node test/failover.test.mjs && node test/global-concurrency-cap.test.mjs && node test/update-check.test.mjs && node test/atomic-write.test.mjs && node test/zombie.test.mjs && node test/zombie-reap-idle.test.mjs && node test/orchestration-marker.test.mjs && node test/orchestration-metering.test.mjs && node test/orchestration-latch.test.mjs && node test/orchestration-handoff.test.mjs && node test/orchestration-template.test.mjs && node test/orchestration-hook-core.test.mjs && node test/orchestration-adapters.test.mjs && node test/orchestration-pretool.test.mjs && node test/orchestration-directives.test.mjs && node test/no-five-call.test.mjs && node test/no-200-line-footprint.test.mjs && node test/no-per-provider-cap.test.mjs && node test/no-api-keys.test.mjs && node test/rag-pointers.test.mjs && node test/check-worktree-subagent.test.mjs && node test/launch-agent-upsert.test.mjs && node test/claude-session-limit.test.mjs && node test/init-migration.test.mjs && node test/init-global.test.mjs && node test/mirror-fragments.test.mjs && node test/performance-tier-effort.test.mjs && node test/setup-repair.test.mjs && node test/setup-quoting.test.mjs && node test/setup-wire.test.mjs && node test/setup-cli-integration.test.mjs && node test/init.test.mjs && node test/handoff-resume-skill.test.mjs && node test/model-selection-mode.test.mjs && node test/cli-args.test.mjs && node test/index-guard.test.mjs && node test/drivers-guard.test.mjs && node test/audit-next13-lb3.test.mjs && node test/zombie-guard.test.mjs && node test/concurrency-guard.test.mjs && node test/hook-core-guard.test.mjs && node scripts/validate_provider.mjs && node scripts/validate_seed_sites.mjs && node scripts/validate_routing_audit.mjs && node test/seed-sites.test.mjs && node test/config-bom.test.mjs && node test/permission-system.test.mjs && node test/lifecycle-matrix.test.mjs && node test/output-hook-registration.test.mjs && node test/mcp-compliance.test.mjs"
43
43
  },
44
44
  "author": "Lexi Blackburn",
45
45
  "license": "Apache-2.0",
@@ -57,8 +57,8 @@
57
57
  "zod": "^4.4.3"
58
58
  },
59
59
  "devDependencies": {
60
- "@types/node": "^20.0.0",
61
- "typescript": "^5.0.0"
60
+ "@types/node": "^26.1.1",
61
+ "typescript": "^7.0.2"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=18"