@heretyc/subagent-mcp 2.12.11 → 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.
- package/README.md +170 -170
- package/directives/carryover-claude.md +0 -2
- package/directives/carryover-codex.md +0 -2
- package/directives/handoff-claude.md +7 -0
- package/directives/handoff-codex.md +7 -0
- package/directives/latch-claude.md +5 -0
- package/directives/latch-codex.md +5 -0
- package/directives/orchestration-claude.md +0 -2
- package/directives/orchestration-codex.md +0 -2
- package/directives/reminder-off-claude.md +1 -3
- package/directives/reminder-off-codex.md +1 -3
- package/directives/reminder-on.md +0 -2
- package/directives/short-off.md +1 -1
- package/directives/short-on.md +1 -1
- package/directives/tag-template.md +2 -0
- package/dist/advanced-ruleset.py +67 -67
- package/dist/config-scaffold.js +1 -1
- package/dist/global-concurrency.jsonc +43 -43
- package/dist/global-subagent-mcp-config.jsonc +43 -43
- package/dist/hooks/orchestration-claude.js +86 -2
- package/dist/hooks/orchestration-codex.js +152 -3
- package/dist/index.js +76 -8
- package/dist/init.js +2 -2
- package/dist/orchestration/handoff.js +142 -0
- package/dist/orchestration/hook-core.js +184 -16
- package/dist/orchestration/latch.js +47 -0
- package/dist/orchestration/marker.js +55 -1
- package/dist/orchestration/metering.js +158 -0
- package/dist/orchestration/pretool.js +3 -3
- package/dist/orchestration/template.js +31 -0
- package/dist/routing-table.json +2133 -3561
- package/dist/routing.js +3 -2
- package/dist/ruleset-scaffold.js +1 -1
- package/package.json +70 -70
- package/scripts/postinstall.mjs +102 -102
|
@@ -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
|
-
|
|
230
|
-
|
|
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 =
|
|
253
|
-
readDirective(env,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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 {
|