@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.
- 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/concurrency.js +117 -16
- package/dist/drivers.js +13 -6
- package/dist/effort.js +5 -3
- package/dist/hooks/orchestration-claude.js +86 -2
- package/dist/hooks/orchestration-codex.js +152 -3
- package/dist/index.js +81 -13
- 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/pending-permissions.js +13 -3
- package/package.json +4 -4
|
@@ -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 {
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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": "^
|
|
61
|
-
"typescript": "^
|
|
60
|
+
"@types/node": "^26.1.1",
|
|
61
|
+
"typescript": "^7.0.2"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
64
|
"node": ">=18"
|