@heretyc/subagent-mcp 2.12.15 → 2.12.17

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,37 @@
1
+ {
2
+ "schema_version": 1,
3
+ "generated": "2026-07-13",
4
+ "notes": "Windows are harness-effective defaults. claude.default is the Claude Code standard tier; claude.long is the [1m] tier where available. codex defaults are effective usable windows matching rollout model_context_window when no per-turn harness value is present.",
5
+ "_comment": "Effective-basis rule: every codex window (default AND long) is stated as the effective usable window = raw_window * 0.95. codex.default 258400 = 272000*0.95; codex.long 950000 = 1000000*0.95. Pairing effective defaults with a raw 1000000 long previously under-stated ratcheted sessions ~5%; both tiers must share the effective basis so percentages stay consistent across a ratchet.",
6
+ "family_defaults": {
7
+ "claude": { "default": 200000, "long": 1000000 }
8
+ },
9
+ "claude": {
10
+ "claude-fable-5": { "default": 200000, "long": 1000000 },
11
+ "claude-mythos-5": { "default": 200000, "long": 1000000 },
12
+ "claude-sonnet-5": { "default": 200000, "long": 1000000 },
13
+ "claude-sonnet-4-6": { "default": 200000, "long": 1000000 },
14
+ "claude-sonnet-4-5": { "default": 200000, "long": 1000000 },
15
+ "claude-sonnet-4-0": { "default": 200000, "long": 1000000 },
16
+ "claude-opus-4-8": { "default": 200000, "long": 1000000 },
17
+ "claude-opus-4-7": { "default": 200000, "long": 1000000 },
18
+ "claude-opus-4-6": { "default": 200000, "long": 1000000 },
19
+ "claude-opus-4-5": { "default": 200000, "long": 1000000 },
20
+ "claude-opus-4-1": { "default": 200000, "long": 1000000 },
21
+ "claude-opus-4-0": { "default": 200000, "long": 1000000 },
22
+ "claude-haiku-4-5": { "default": 200000, "long": null }
23
+ },
24
+ "codex": {
25
+ "gpt-5.5": { "default": 258400, "long": null },
26
+ "gpt-5.4": { "default": 258400, "long": 950000 },
27
+ "gpt-5.4-mini": { "default": 258400, "long": null },
28
+ "gpt-5.3-codex-spark": { "default": 121600, "long": null },
29
+ "gpt-5.2-codex": { "default": 258400, "long": null },
30
+ "gpt-5-codex": { "default": 258400, "long": null },
31
+ "gpt-5": { "default": 258400, "long": null },
32
+ "codex-auto-review": { "default": 258400, "long": 950000 },
33
+ "o3": { "default": 200000, "long": null },
34
+ "o3-mini": { "default": 200000, "long": null },
35
+ "o4-mini": { "default": 200000, "long": null }
36
+ }
37
+ }
@@ -1,6 +1,7 @@
1
1
  import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync, } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { countJsonlType, runHook, TRANSCRIPT_READ_CAP, } from "../orchestration/hook-core.js";
4
+ import { readClaudeLongContextHint } from "../orchestration/settings-hint.js";
4
5
  import { hasParentMarker } from "../launch-prompt.js";
5
6
  /**
6
7
  * Claude Code UserPromptSubmit hook entry. Reads the JSON payload from stdin,
@@ -75,6 +76,8 @@ function liftClaudeUsageFromTranscript(transcriptPath) {
75
76
  continue;
76
77
  try {
77
78
  const msg = JSON.parse(trimmed);
79
+ if (msg.isSidechain === true)
80
+ continue;
78
81
  if (msg.type !== "assistant" || !msg.message?.usage)
79
82
  continue;
80
83
  if (typeof msg.message.model !== "string")
@@ -126,8 +129,14 @@ export const claudeAdapter = {
126
129
  currentTurn(transcriptPath) {
127
130
  return countJsonlType(transcriptPath, "user");
128
131
  },
129
- liftUsage(_payload, _env, transcriptPath) {
130
- return liftClaudeUsageFromTranscript(transcriptPath);
132
+ liftUsage(payload, env, transcriptPath) {
133
+ const lifted = liftClaudeUsageFromTranscript(transcriptPath);
134
+ if (lifted === null)
135
+ return null;
136
+ return {
137
+ ...lifted,
138
+ longContextHint: readClaudeLongContextHint(payload.cwd, env),
139
+ };
131
140
  },
132
141
  anonScope: "claude",
133
142
  fullDirectiveFile: "orchestration-claude.md",
@@ -2,7 +2,6 @@ import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync, }
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { claimAndEmit, classifyOwnerClaim, computeEffectiveActive, countJsonlType, cullHookZombies, ownerKey, runHook, TRANSCRIPT_READ_CAP, } from "../orchestration/hook-core.js";
4
4
  import * as marker from "../orchestration/marker.js";
5
- import { resolveContextWindow, } from "../orchestration/metering.js";
6
5
  import { isParentProcessMarkerFirstLine } from "../launch-prompt.js";
7
6
  /**
8
7
  * Codex CLI hook entry. Branches on payload.hook_event_name:
@@ -85,6 +84,25 @@ function stringField(value, key) {
85
84
  const child = value[key];
86
85
  return typeof child === "string" ? child : null;
87
86
  }
87
+ function isSubagentSourceObject(source) {
88
+ if (!source || typeof source !== "object")
89
+ return false;
90
+ const record = source;
91
+ if (Object.prototype.hasOwnProperty.call(record, "subagent")) {
92
+ return true;
93
+ }
94
+ for (const kind of SUBAGENT_SOURCE_STRINGS) {
95
+ if (Object.prototype.hasOwnProperty.call(record, kind)) {
96
+ return true;
97
+ }
98
+ }
99
+ // Local Codex rollouts did not verify the subagent object schema. Accept the
100
+ // known subagent kind strings if Codex places them in a discriminator field.
101
+ const kind = record.kind;
102
+ const type = record.type;
103
+ return ((typeof kind === "string" && SUBAGENT_SOURCE_STRINGS.has(kind)) ||
104
+ (typeof type === "string" && SUBAGENT_SOURCE_STRINGS.has(type)));
105
+ }
88
106
  function isTokenCountLine(obj) {
89
107
  if (obj.type === "token_count")
90
108
  return true;
@@ -134,6 +152,7 @@ function liftCodexUsageFromRollout(transcriptPath) {
134
152
  if (!finiteNumber(input) || !finiteNumber(output) || !finiteNumber(cacheRead)) {
135
153
  return null;
136
154
  }
155
+ const nonCachedInput = Math.max(0, input - cacheRead);
137
156
  const modelContextWindow = latestTokenInfo?.model_context_window;
138
157
  const totalTokens = total.total_tokens;
139
158
  const harnessPercentage = finiteNumber(modelContextWindow) &&
@@ -141,15 +160,17 @@ function liftCodexUsageFromRollout(transcriptPath) {
141
160
  finiteNumber(totalTokens)
142
161
  ? (totalTokens / modelContextWindow) * 100
143
162
  : null;
144
- if (harnessPercentage === null && resolveContextWindow("codex", latestModel) === null) {
145
- return null;
146
- }
163
+ // Always return the lift even when neither a harness percentage nor a static
164
+ // window is available (unknown codex model). hook-core is the sole writer and
165
+ // persists a null-window "unknown + fail-safe" metering record — matching the
166
+ // Claude adapter, which likewise never suppresses the record on unresolved
167
+ // windows. Suppressing here would leave unknown codex models with NO record.
147
168
  return {
148
169
  harness: "codex",
149
170
  model: latestModel,
150
171
  source_ref: transcriptPath,
151
172
  usage: {
152
- input,
173
+ input: nonCachedInput,
153
174
  output,
154
175
  cache_creation: 0,
155
176
  cache_read: cacheRead,
@@ -164,11 +185,9 @@ export const codexAdapter = {
164
185
  return true;
165
186
  }
166
187
  const source = payload.source;
167
- // 0.131+: source is an object whose keys name the subagent kind.
168
- if (source && typeof source === "object") {
169
- if (Object.prototype.hasOwnProperty.call(source, "subagent")) {
170
- return true;
171
- }
188
+ // 0.131+: source may be an object whose keys name the subagent kind.
189
+ if (isSubagentSourceObject(source)) {
190
+ return true;
172
191
  }
173
192
  // Older: source is a string enum.
174
193
  if (typeof source === "string" && SUBAGENT_SOURCE_STRINGS.has(source)) {
package/dist/index.js CHANGED
@@ -572,7 +572,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
572
572
  const SUBAGENT_INSTRUCTIONS = "SUB-AGENT SESSION: you are a child process launched by subagent-mcp. Follow the parent prompt. Do not treat yourself as the orchestrator, do not re-trigger orchestration carryover, and do not launch further sub-agents unless the parent prompt explicitly assigns that. launch_agent is code-capped at 2 spawn levels below the main orchestrator: depth 1 may launch depth 2 workers; depth 2 workers cannot spawn further.\n\nMODEL SELECTION MODE (parallel to orchestration-mode, set via the model-selection-mode tool). DEFAULT is \"smart\" and is used whenever unset: in smart, launch_agent REJECTS any call supplying provider/model/effort selectors and the server auto-picks the best model. \"user-approved-overrides\" opens a 30-MINUTE window where selectors are HONORED, enforced LAZILY (the mode reverts to smart on the next launch_agent call after 30 minutes) and re-enabling does NOT extend an active window. HONOR-BASED: you MUST NOT set \"user-approved-overrides\" without explicit interactive USER authorization via the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex); never enable it on your own initiative.";
573
573
  const server = new McpServer({
574
574
  name: "subagent-mcp",
575
- version: "2.12.15",
575
+ version: "2.12.17",
576
576
  description: "Launches always-interactive local Claude and Codex sub-agent sessions and is the orchestrator's sole launch channel. Claude runs via the Claude Agent SDK over the local Claude Code executable; Codex via `codex app-server` over stdio. The server never calls Anthropic or OpenAI HTTP APIs directly.",
577
577
  }, {
578
578
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -1,6 +1,7 @@
1
1
  import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { fileURLToPath } from "node:url";
3
- import { dirname, isAbsolute, join } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
5
  import * as marker from "./marker.js";
5
6
  import * as reminder from "./reminder.js";
6
7
  import * as handoff from "./handoff.js";
@@ -38,11 +39,14 @@ const OWNER_CLAIM_CAP = 8;
38
39
  /**
39
40
  * Resolve the repo-root `directives/` dir at runtime. Honors an explicit plugin
40
41
  * root (Claude sets CLAUDE_PLUGIN_ROOT; a generic PLUGIN_ROOT is also accepted)
41
- * so the bundled plugin finds its assets wherever it is installed. Otherwise we
42
- * walk up from the COMPILED file location: dist/hooks/<x>.js -> ../../directives
43
- * === <repoRoot>/directives.
42
+ * so the bundled plugin finds its assets wherever it is installed, but only
43
+ * when that root is under an expected install prefix. Otherwise we walk up from
44
+ * the COMPILED file location: dist/hooks/<x>.js -> ../../directives ===
45
+ * <repoRoot>/directives.
44
46
  */
45
47
  export function resolveDirectivesDir(env) {
48
+ const here = dirname(fileURLToPath(import.meta.url));
49
+ const compiledRoot = resolve(here, "..", "..");
46
50
  const rootEnvName = env.CLAUDE_PLUGIN_ROOT !== undefined
47
51
  ? "CLAUDE_PLUGIN_ROOT"
48
52
  : env.PLUGIN_ROOT !== undefined
@@ -51,17 +55,57 @@ export function resolveDirectivesDir(env) {
51
55
  if (rootEnvName) {
52
56
  const root = env[rootEnvName] ?? "";
53
57
  const directivesDir = join(root, "directives");
54
- if (!isAbsolute(root) || !existsSync(directivesDir)) {
55
- throw new Error(`${rootEnvName} must be an absolute path with an existing directives directory: ${root}`);
58
+ if (isAbsolute(root) &&
59
+ existsSync(directivesDir) &&
60
+ isTrustedPluginRoot(root, env, compiledRoot)) {
61
+ return directivesDir;
56
62
  }
57
- return directivesDir;
58
63
  }
59
- const here = dirname(fileURLToPath(import.meta.url));
60
64
  // Compiled location is dist/orchestration/hook-core.js, so ../../directives
61
65
  // is the repo root's directives dir; the entry shims live at dist/hooks/<x>.js
62
66
  // and import this module, but __dirname here is the hook-core module's own
63
67
  // dir. Two levels up from dist/orchestration is the repo root either way.
64
- return join(here, "..", "..", "directives");
68
+ return join(compiledRoot, "directives");
69
+ }
70
+ function normalizePathKey(pathValue) {
71
+ let p = resolve(pathValue);
72
+ p = p.replace(/\\/g, "/");
73
+ if (process.platform === "win32") {
74
+ p = p.toLowerCase();
75
+ }
76
+ if (p.length > 1 && p.endsWith("/")) {
77
+ p = p.slice(0, -1);
78
+ }
79
+ return p;
80
+ }
81
+ function isPathUnder(pathValue, prefix) {
82
+ const child = normalizePathKey(pathValue);
83
+ const parent = normalizePathKey(prefix);
84
+ return child === parent || child.startsWith(parent + "/");
85
+ }
86
+ function installPrefixes(env, compiledRoot) {
87
+ const prefixes = [
88
+ compiledRoot,
89
+ env.npm_config_prefix,
90
+ env.PREFIX,
91
+ process.env.npm_config_prefix,
92
+ process.env.PREFIX,
93
+ process.platform === "win32" && env.APPDATA ? join(env.APPDATA, "npm") : undefined,
94
+ process.platform === "win32" && process.env.APPDATA
95
+ ? join(process.env.APPDATA, "npm")
96
+ : undefined,
97
+ join(dirname(process.execPath), ".."),
98
+ join(homedir(), ".claude", "plugins"),
99
+ join(homedir(), ".codex", "plugins", "cache"),
100
+ join(homedir(), ".codex", "plugins"),
101
+ ];
102
+ return [...new Set(prefixes.filter((p) => typeof p === "string" && p.length > 0))];
103
+ }
104
+ function isTrustedPluginRoot(root, env, compiledRoot) {
105
+ // Trust assumption: env roots are host-controlled only after they resolve
106
+ // under the package install, npm global prefix, or known plugin cache roots.
107
+ const resolvedRoot = resolve(root);
108
+ return installPrefixes(env, compiledRoot).some((prefix) => isPathUnder(resolvedRoot, prefix));
65
109
  }
66
110
  /** Read a directive asset by filename. On ANY failure return '' (fail-safe). */
67
111
  export function readDirective(env, fileName) {
@@ -171,7 +215,9 @@ export function sessionKey(payload) {
171
215
  }
172
216
  if (typeof payload.transcript_path === "string" &&
173
217
  payload.transcript_path.length > 0) {
174
- return "tp-" + marker.hashKey(payload.transcript_path);
218
+ // Residual caveat: a genuinely moved transcript still re-keys. Prefer the
219
+ // host session_id when present, which remains the precedence above.
220
+ return "tp-" + marker.hashKey(normalizePathKey(payload.transcript_path));
175
221
  }
176
222
  return undefined;
177
223
  }
@@ -298,16 +344,7 @@ function updateMeteringForTurn(payload, env, adapter, current, turnIndex) {
298
344
  const lifted = adapter.liftUsage(payload, env, payload.transcript_path);
299
345
  if (lifted === null)
300
346
  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
- }
347
+ const prior = metering.readMetering(current);
311
348
  const record = metering.buildMeteringRecord({
312
349
  session_id: current,
313
350
  harness: lifted.harness,
@@ -318,9 +355,18 @@ function updateMeteringForTurn(payload, env, adapter, current, turnIndex) {
318
355
  ? payload.hook_event_name
319
356
  : "UserPromptSubmit",
320
357
  harnessPercentage: lifted.harnessPercentage,
358
+ longContextHint: lifted.longContextHint,
359
+ priorWindow: prior?.context_window_size ?? null,
360
+ priorWindowSource: prior?.window_source ?? null,
361
+ priorWindowFloor: prior?.window_floor ?? null,
321
362
  });
322
363
  metering.writeMetering(current, record);
323
- return false;
364
+ // A valid harness-reported percentage stands on its own. Without one, a null
365
+ // window means the host is undetectable for this turn, even though the record
366
+ // is still persisted for observability and same-turn fail-safe agreement.
367
+ const hasHarnessPercentage = typeof lifted.harnessPercentage === "number" &&
368
+ Number.isFinite(lifted.harnessPercentage);
369
+ return !hasHarnessPercentage && record.context_window_size === null;
324
370
  }
325
371
  function providerDirectiveFile(adapter, prefix) {
326
372
  return `${prefix}-${adapter.anonScope}.md`;
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, unlinkSync, } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { atomicWriteJson } from "./atomic-write.js";
4
4
  import { hashKey, stateDir } from "./marker.js";
5
+ export const LATCH_REV = 2;
5
6
  export function latchPath(sessionKey) {
6
7
  return join(stateDir, `latch-${hashKey(sessionKey)}.json`);
7
8
  }
@@ -10,10 +11,20 @@ export function isLatchActive(sessionKey, now) {
10
11
  try {
11
12
  const raw = readFileSync(latchPath(sessionKey), "utf8");
12
13
  const parsed = JSON.parse(raw);
13
- return (parsed.latched === true &&
14
+ const active = parsed.rev === LATCH_REV &&
15
+ parsed.latched === true &&
14
16
  typeof parsed.latched_at === "number" &&
15
17
  Number.isFinite(parsed.latched_at) &&
16
- typeof parsed.session_id === "string");
18
+ typeof parsed.session_id === "string";
19
+ if (!active) {
20
+ try {
21
+ unlinkSync(latchPath(sessionKey));
22
+ }
23
+ catch {
24
+ // Best-effort lazy migration cleanup.
25
+ }
26
+ }
27
+ return active;
17
28
  }
18
29
  catch {
19
30
  return false;
@@ -25,6 +36,7 @@ export function tripLatch(sessionKey, now) {
25
36
  try {
26
37
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
27
38
  atomicWriteJson(latchPath(sessionKey), {
39
+ rev: LATCH_REV,
28
40
  latched: true,
29
41
  latched_at: now,
30
42
  session_id: sessionKey,
@@ -1,43 +1,93 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync, } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { atomicWriteJson } from "./atomic-write.js";
4
5
  import { hashKey, ORCH_DISABLE_TTL_MS, stateDir, } from "./marker.js";
5
6
  export const PLAN_LATCH_THRESHOLD_PCT = 15;
6
7
  export const HANDOFF_UNLOCK_THRESHOLD_PCT = 50;
7
8
  export const DEFAULT_CONTEXT_WINDOW = 200000;
8
9
  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
10
  const EMPTY_USAGE = {
30
11
  input: 0,
31
12
  output: 0,
32
13
  cache_creation: 0,
33
14
  cache_read: 0,
34
15
  };
16
+ let cachedWindowTable;
17
+ let contextWindowsPathOverride = null;
35
18
  function finiteNumber(value) {
36
19
  return typeof value === "number" && Number.isFinite(value);
37
20
  }
21
+ function isPositiveInteger(value) {
22
+ return Number.isInteger(value) && value > 0;
23
+ }
24
+ function isContextWindowEntry(value) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value))
26
+ return false;
27
+ const record = value;
28
+ return (isPositiveInteger(record.default) &&
29
+ (record.long === null ||
30
+ (isPositiveInteger(record.long) && record.long > record.default)));
31
+ }
32
+ function isContextWindowMap(value) {
33
+ if (!value || typeof value !== "object" || Array.isArray(value))
34
+ return false;
35
+ return Object.values(value).every(isContextWindowEntry);
36
+ }
37
+ function isContextWindowTable(value) {
38
+ if (!value || typeof value !== "object" || Array.isArray(value))
39
+ return false;
40
+ const record = value;
41
+ const familyDefaults = record.family_defaults;
42
+ const claudeFamilyDefault = !familyDefaults ||
43
+ (typeof familyDefaults === "object" &&
44
+ familyDefaults !== null &&
45
+ !Array.isArray(familyDefaults) &&
46
+ familyDefaults?.claude !== undefined &&
47
+ isContextWindowEntry(familyDefaults?.claude));
48
+ return (record.schema_version === 1 &&
49
+ claudeFamilyDefault &&
50
+ isContextWindowMap(record.claude) &&
51
+ isContextWindowMap(record.codex));
52
+ }
53
+ function contextWindowsPath() {
54
+ return (contextWindowsPathOverride ??
55
+ fileURLToPath(new URL("../context-windows.json", import.meta.url)));
56
+ }
57
+ export function setContextWindowsPathForTest(path) {
58
+ contextWindowsPathOverride = path;
59
+ cachedWindowTable = undefined;
60
+ }
61
+ function loadContextWindowTable() {
62
+ if (cachedWindowTable !== undefined)
63
+ return cachedWindowTable;
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(contextWindowsPath(), "utf8").replace(/^\uFEFF/, ""));
66
+ cachedWindowTable = isContextWindowTable(parsed) ? parsed : null;
67
+ }
68
+ catch {
69
+ cachedWindowTable = null;
70
+ }
71
+ return cachedWindowTable;
72
+ }
73
+ export function normalizeModelId(modelId) {
74
+ if (typeof modelId !== "string")
75
+ return null;
76
+ let base = modelId.trim().toLowerCase();
77
+ if (!base)
78
+ return null;
79
+ let idMarker = /\[1m\]/i.test(base) || /-1m\b/i.test(base);
80
+ base = base.replace(/\[[^\]]+\]/g, "");
81
+ base = base.replace(/-1m\b/g, "");
82
+ const dated = base.replace(/-(20\d{6})$/, "");
83
+ base = dated;
84
+ if (!base)
85
+ return null;
86
+ return { base, idMarker };
87
+ }
38
88
  function normalizeUsage(usage) {
39
89
  if (usage === null || usage === undefined) {
40
- return { usage: { ...EMPTY_USAGE }, used_tokens: null };
90
+ return { usage: { ...EMPTY_USAGE }, used_tokens: null, prompt_side_tokens: null };
41
91
  }
42
92
  const normalized = {
43
93
  input: finiteNumber(usage.input) ? usage.input : 0,
@@ -51,35 +101,111 @@ function normalizeUsage(usage) {
51
101
  normalized.output +
52
102
  normalized.cache_creation +
53
103
  normalized.cache_read,
104
+ prompt_side_tokens: normalized.input + normalized.cache_creation + normalized.cache_read,
54
105
  };
55
106
  }
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;
107
+ function usablePriorFloor(priorWindow, priorSource, priorWindowFloor) {
108
+ // The monotonic floor tracks the highest OBSERVED prompt-side token count,
109
+ // never the resolved window size. window_source gating lives in how
110
+ // priorWindowFloor is produced: hint-only turns record their observed tokens
111
+ // (not the 1M hint window), so a hint window never becomes sticky, while a
112
+ // ratcheted/prior turn carries the real observed floor forward.
113
+ void priorWindow;
114
+ void priorSource;
115
+ return finiteNumber(priorWindowFloor) ? priorWindowFloor : null;
116
+ }
117
+ function maybeApplyLong(candidate, source, long, enabled) {
118
+ if (enabled && long !== null && candidate < long) {
119
+ return { candidate: long, source: "hint" };
65
120
  }
66
- if (harness === "codex") {
67
- if (!CODEX_KNOWN_MODEL_IDS.includes(modelId)) {
68
- return null;
121
+ return { candidate, source };
122
+ }
123
+ export function resolveContextWindowDetailed(input) {
124
+ const normalized = normalizeModelId(input.modelId);
125
+ if (normalized === null) {
126
+ return { window: null, source: null, window_floor: null, contradiction: false };
127
+ }
128
+ const table = loadContextWindowTable();
129
+ if (table === null) {
130
+ return { window: null, source: null, window_floor: null, contradiction: false };
131
+ }
132
+ const promptSideTokens = finiteNumber(input.promptSideTokens)
133
+ ? input.promptSideTokens
134
+ : null;
135
+ const priorFloor = usablePriorFloor(input.priorWindow, input.priorWindowSource, input.priorWindowFloor);
136
+ const observedFloor = Math.max(promptSideTokens ?? 0, priorFloor ?? 0);
137
+ const window_floor = observedFloor > 0 ? observedFloor : null;
138
+ if (input.harness === "claude") {
139
+ if (!/^claude-/i.test(normalized.base)) {
140
+ return { window: null, source: null, window_floor, contradiction: false };
141
+ }
142
+ const entry = table.claude[normalized.base] ?? table.family_defaults?.claude ?? null;
143
+ if (entry === null) {
144
+ return { window: null, source: null, window_floor, contradiction: false };
69
145
  }
70
- if (/-1m\b|\[1m\]/i.test(modelId))
71
- return LONG_CONTEXT_WINDOW;
72
- return CODEX_CONTEXT_WINDOW_BY_MODEL_ID[modelId];
146
+ const mapped = Object.prototype.hasOwnProperty.call(table.claude, normalized.base);
147
+ let candidate = entry.default;
148
+ let source = mapped ? "mapping" : "family-default";
149
+ const hinted = normalized.idMarker || input.longContextHint === true;
150
+ ({ candidate, source } = maybeApplyLong(candidate, source, entry.long, hinted));
151
+ if (promptSideTokens !== null && promptSideTokens > candidate) {
152
+ if (entry.long !== null && promptSideTokens <= entry.long) {
153
+ candidate = entry.long;
154
+ source = "ratchet";
155
+ }
156
+ else {
157
+ return { window: null, source: "contradiction", window_floor, contradiction: true };
158
+ }
159
+ }
160
+ if (priorFloor !== null && priorFloor > candidate) {
161
+ if (entry.long !== null && priorFloor <= entry.long) {
162
+ candidate = entry.long;
163
+ source = "prior";
164
+ }
165
+ else {
166
+ return { window: null, source: "contradiction", window_floor, contradiction: true };
167
+ }
168
+ }
169
+ return { window: candidate, source, window_floor, contradiction: false };
170
+ }
171
+ if (input.harness === "codex") {
172
+ const entry = table.codex[normalized.base] ?? null;
173
+ if (entry === null) {
174
+ return { window: null, source: null, window_floor, contradiction: false };
175
+ }
176
+ let candidate = entry.default;
177
+ let source = "mapping";
178
+ ({ candidate, source } = maybeApplyLong(candidate, source, entry.long, normalized.idMarker));
179
+ if (promptSideTokens !== null && promptSideTokens > candidate) {
180
+ if (entry.long !== null && promptSideTokens <= entry.long) {
181
+ candidate = entry.long;
182
+ source = "ratchet";
183
+ }
184
+ else {
185
+ return { window: null, source: "contradiction", window_floor, contradiction: true };
186
+ }
187
+ }
188
+ if (priorFloor !== null && priorFloor > candidate) {
189
+ if (entry.long !== null && priorFloor <= entry.long) {
190
+ candidate = entry.long;
191
+ source = "prior";
192
+ }
193
+ else {
194
+ return { window: null, source: "contradiction", window_floor, contradiction: true };
195
+ }
196
+ }
197
+ return { window: candidate, source, window_floor, contradiction: false };
73
198
  }
74
- return null;
199
+ return { window: null, source: null, window_floor, contradiction: false };
200
+ }
201
+ export function resolveContextWindow(harness, modelId) {
202
+ return resolveContextWindowDetailed({ harness, modelId }).window;
75
203
  }
76
204
  export function meteringPath(sessionKey, stateDirOverride = stateDir) {
77
205
  return join(stateDirOverride, "ctx-" + hashKey(sessionKey) + ".json");
78
206
  }
79
207
  export function computeUsedPercentage(record) {
80
208
  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
209
  return Math.min(100, Math.max(0, record.harnessPercentage));
84
210
  }
85
211
  if (record.used_tokens === null || record.context_window_size === null) {
@@ -97,10 +223,18 @@ export function phaseFor(usedPercentage) {
97
223
  : "normal";
98
224
  }
99
225
  export function buildMeteringRecord(input) {
100
- const context_window_size = resolveContextWindow(input.harness, input.model);
101
226
  const normalized = normalizeUsage(input.usage);
227
+ const resolution = resolveContextWindowDetailed({
228
+ harness: input.harness,
229
+ modelId: input.model,
230
+ longContextHint: input.longContextHint,
231
+ promptSideTokens: normalized.prompt_side_tokens,
232
+ priorWindow: input.priorWindow,
233
+ priorWindowSource: input.priorWindowSource,
234
+ priorWindowFloor: input.priorWindowFloor,
235
+ });
102
236
  const used_percentage = computeUsedPercentage({
103
- context_window_size,
237
+ context_window_size: resolution.window,
104
238
  used_tokens: normalized.used_tokens,
105
239
  harnessPercentage: input.harnessPercentage,
106
240
  });
@@ -109,7 +243,9 @@ export function buildMeteringRecord(input) {
109
243
  harness: input.harness,
110
244
  model: input.model,
111
245
  source_ref: input.source_ref,
112
- context_window_size,
246
+ context_window_size: resolution.window,
247
+ window_source: resolution.source,
248
+ window_floor: resolution.window_floor,
113
249
  usage: normalized.usage,
114
250
  used_tokens: normalized.used_tokens,
115
251
  used_percentage,
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join } from "node:path";
4
+ function readModelFromSettings(path) {
5
+ try {
6
+ if (!existsSync(path))
7
+ return undefined;
8
+ const parsed = JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, ""));
9
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
10
+ return undefined;
11
+ const model = parsed.model;
12
+ return typeof model === "string" ? model : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function configDir(env) {
19
+ const raw = env.CLAUDE_CONFIG_DIR;
20
+ if (typeof raw === "string" && raw.trim()) {
21
+ return isAbsolute(raw) ? raw : join(homedir(), raw);
22
+ }
23
+ return join(homedir(), ".claude");
24
+ }
25
+ export function readClaudeLongContextHint(cwd, env) {
26
+ const envModel = env.ANTHROPIC_MODEL;
27
+ if (typeof envModel === "string") {
28
+ return /\[1m\]/i.test(envModel);
29
+ }
30
+ // Without a session cwd there is no session to attribute a model to, so the
31
+ // machine-global settings fallback must NOT leak in. Real hooks always pass a
32
+ // cwd (so project + global lookups still apply in production); a payload with
33
+ // no cwd and no ANTHROPIC_MODEL yields a fail-safe null hint.
34
+ if (!cwd)
35
+ return null;
36
+ const paths = [
37
+ join(cwd, ".claude", "settings.local.json"),
38
+ join(cwd, ".claude", "settings.json"),
39
+ join(configDir(env), "settings.json"),
40
+ ];
41
+ for (const path of paths) {
42
+ const model = readModelFromSettings(path);
43
+ if (typeof model === "string") {
44
+ return /\[1m\]/i.test(model);
45
+ }
46
+ }
47
+ return null;
48
+ }
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { readCheckForUpdates } from "../concurrency.js";
4
4
  import { atomicWriteJson } from "./atomic-write.js";
5
5
  import { stateDir } from "./marker.js";
6
- export const UPDATE_NOTICE_TEXT = "Notice: An improved version of subagent-mcp is available via the CLI command `subagent-mcp update` and can then be fully installed with `subagent-mcp setup`. This will fix security issues and improve user experience.";
6
+ export const UPDATE_NOTICE_TEXT = "Notice: An improved version of subagent-mcp is available via the CLI command `subagent-mcp update` and can then be fully installed with `subagent-mcp setup`. This may include security and user experience improvements.";
7
7
  export const UPDATE_CHECK_TIMEOUT_MS = 2500;
8
8
  export const UPDATE_NOTICE_INTERVAL_MS = 12 * 60 * 60 * 1000;
9
9
  function pendingNoticePath() {
@@ -165,6 +165,7 @@ export function appendUpdateNotice(out, installedVersion, sessionId, env = proce
165
165
  notified_at: now,
166
166
  ...(sessionId ? { session_id: sessionId } : {}),
167
167
  });
168
+ // Injection invariant: NEVER interpolate registry-sourced strings into injected text.
168
169
  return `${out}\n${UPDATE_NOTICE_TEXT}`;
169
170
  }
170
171
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.12.15",
3
+ "version": "2.12.17",
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-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"
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 scripts/validate_context_windows.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",