@heretyc/subagent-mcp 2.12.12 → 2.12.13

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,142 @@
1
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { atomicWriteJson } from "./atomic-write.js";
4
+ import { cwdHash, stateDir } from "./marker.js";
5
+ export const HANDOFF_THRESHOLD_PCT = 50;
6
+ export const HANDOFF_CONTENT_LIMIT = 4000;
7
+ export const HANDOFF_OVERFLOW_LIMIT = 8000;
8
+ export const UNAVAILABLE_NO_METERING = "handoff-write is not available due to missing context size data. It will become available once context usage can be measured for this session.";
9
+ export const UNAVAILABLE_BELOW_50 = "handoff-write is not available until this session reaches 50% context utilization (currently below threshold).";
10
+ export const OVERSIZE_CONTENT = "handoff content exceeds the 4000-character limit; shorten it, or move the excess (up to 8000 additional characters) into a separate file and reference its full path inside the 4000-character content.";
11
+ export const OVERSIZE_OVERFLOW = "handoff overflow content exceeds the 8000-character limit; shorten the overflow file content and retry.";
12
+ export const NO_HANDOFF_FOUND = "No handoff found for this directory. Resume the previous session and ask it to write one via handoff-write.";
13
+ export const HANDOFF_WRITE_SUCCESS = "We are ready to start a new session, to avoid wasting tokens, use the structured question tool to confirm that the user is ready to use the `handoff-resume skill` in the next new session to resume work and has cleared the current /goal (if present) - or you will be compelled to keep working on a potential /goal that needs to be halted for a new session.";
14
+ export function handoffPath(cwd) {
15
+ return join(stateDir, "handoff-" + cwdHash(cwd) + ".json");
16
+ }
17
+ export function handoffOverflowPath(cwd, now = Date.now()) {
18
+ return join(stateDir, "handoff-overflow-" + cwdHash(cwd) + "-" + now + ".md");
19
+ }
20
+ export function checkHandoffWriteAvailable(metering) {
21
+ const used = metering?.used_percentage;
22
+ if (typeof used !== "number" || !Number.isFinite(used)) {
23
+ return { ok: false, error: UNAVAILABLE_NO_METERING };
24
+ }
25
+ if (used < HANDOFF_THRESHOLD_PCT) {
26
+ return { ok: false, error: UNAVAILABLE_BELOW_50 };
27
+ }
28
+ return { ok: true };
29
+ }
30
+ export function readHandoff(cwd) {
31
+ try {
32
+ const raw = readFileSync(handoffPath(cwd), "utf8");
33
+ return validateHandoffRecord(JSON.parse(raw));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function writeHandoff(cwd, input) {
40
+ if (input.content.length > HANDOFF_CONTENT_LIMIT) {
41
+ return { ok: false, error: OVERSIZE_CONTENT };
42
+ }
43
+ const overflowContent = input.overflowContent ?? "";
44
+ if (overflowContent.length > HANDOFF_OVERFLOW_LIMIT) {
45
+ return { ok: false, error: OVERSIZE_OVERFLOW };
46
+ }
47
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
48
+ const overflowPath = overflowContent.length > 0 ? handoffOverflowPath(cwd) : null;
49
+ if (overflowPath !== null) {
50
+ writeFileSync(overflowPath, overflowContent, { encoding: "utf8", mode: 0o600 });
51
+ }
52
+ const record = {
53
+ content: input.content,
54
+ overflow_path: overflowPath,
55
+ created_at: Date.now(),
56
+ created_by_session: input.createdBySession,
57
+ read_by_session: null,
58
+ read_at: null,
59
+ };
60
+ atomicWriteJson(handoffPath(cwd), record, { encoding: "utf8", mode: 0o600 });
61
+ return { ok: true, record };
62
+ }
63
+ export function writeHandoffIfAvailable(cwd, input, metering) {
64
+ const gate = checkHandoffWriteAvailable(metering);
65
+ if (!gate.ok)
66
+ return gate;
67
+ return writeHandoff(cwd, input);
68
+ }
69
+ export function markRead(cwd, sessionKey) {
70
+ const record = readHandoff(cwd);
71
+ if (record === null)
72
+ return null;
73
+ const next = {
74
+ ...record,
75
+ read_by_session: sessionKey,
76
+ read_at: Date.now(),
77
+ };
78
+ try {
79
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
80
+ atomicWriteJson(handoffPath(cwd), next, { encoding: "utf8", mode: 0o600 });
81
+ return next;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ export function clearHandoff(cwd) {
88
+ const record = readHandoff(cwd);
89
+ unlinkIfPresent(handoffPath(cwd));
90
+ if (record?.overflow_path) {
91
+ unlinkIfPresent(record.overflow_path);
92
+ }
93
+ }
94
+ function unlinkIfPresent(path) {
95
+ try {
96
+ unlinkSync(path);
97
+ }
98
+ catch (e) {
99
+ if (e?.code !== "ENOENT") {
100
+ throw e;
101
+ }
102
+ }
103
+ }
104
+ function validateHandoffRecord(value) {
105
+ if (value === null || typeof value !== "object")
106
+ return null;
107
+ const record = value;
108
+ const content = record.content;
109
+ const overflowPath = record.overflow_path;
110
+ const createdAt = record.created_at;
111
+ const createdBySession = record.created_by_session;
112
+ const readBySession = record.read_by_session;
113
+ const readAt = record.read_at;
114
+ if (typeof content !== "string")
115
+ return null;
116
+ if (content.length > HANDOFF_CONTENT_LIMIT)
117
+ return null;
118
+ if (!isValidOverflowPath(overflowPath))
119
+ return null;
120
+ if (!isFiniteNumber(createdAt))
121
+ return null;
122
+ if (typeof createdBySession !== "string")
123
+ return null;
124
+ if (readBySession !== null && typeof readBySession !== "string")
125
+ return null;
126
+ if (readAt !== null && !isFiniteNumber(readAt))
127
+ return null;
128
+ return {
129
+ content,
130
+ overflow_path: overflowPath,
131
+ created_at: createdAt,
132
+ created_by_session: createdBySession,
133
+ read_by_session: readBySession,
134
+ read_at: readAt,
135
+ };
136
+ }
137
+ function isValidOverflowPath(path) {
138
+ return path === null || (typeof path === "string" && isAbsolute(path));
139
+ }
140
+ function isFiniteNumber(value) {
141
+ return typeof value === "number" && Number.isFinite(value);
142
+ }
@@ -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 {