@heretyc/subagent-mcp 2.12.16 → 2.12.18

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.
@@ -26,6 +26,11 @@ const SUBAGENT_ENTRYPOINTS = new Set([
26
26
  function finiteNumber(value) {
27
27
  return typeof value === "number" && Number.isFinite(value);
28
28
  }
29
+ function isRealClaudeModel(value) {
30
+ return (typeof value === "string" &&
31
+ value.trim() !== "" &&
32
+ value.trim() !== "<synthetic>");
33
+ }
29
34
  function readTranscriptTail(transcriptPath) {
30
35
  if (!transcriptPath)
31
36
  return null;
@@ -80,12 +85,12 @@ function liftClaudeUsageFromTranscript(transcriptPath) {
80
85
  continue;
81
86
  if (msg.type !== "assistant" || !msg.message?.usage)
82
87
  continue;
83
- if (typeof msg.message.model !== "string")
84
- return null;
88
+ if (!isRealClaudeModel(msg.message.model))
89
+ continue;
85
90
  const usage = msg.message.usage;
86
91
  return {
87
92
  harness: "claude",
88
- model: msg.message.model,
93
+ model: msg.message.model.trim(),
89
94
  source_ref: transcriptPath,
90
95
  usage: {
91
96
  input: finiteNumber(usage.input_tokens) ? usage.input_tokens : 0,
@@ -97,6 +102,9 @@ function liftClaudeUsageFromTranscript(transcriptPath) {
97
102
  ? usage.cache_read_input_tokens
98
103
  : 0,
99
104
  },
105
+ // Claude Code statusline receives context_window.used_percentage on its
106
+ // own stdin payload. Recent hook transcripts do not expose that object,
107
+ // so this adapter cannot pass it through without a hook-readable signal.
100
108
  harnessPercentage: null,
101
109
  };
102
110
  }
@@ -84,6 +84,25 @@ function stringField(value, key) {
84
84
  const child = value[key];
85
85
  return typeof child === "string" ? child : null;
86
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
+ }
87
106
  function isTokenCountLine(obj) {
88
107
  if (obj.type === "token_count")
89
108
  return true;
@@ -166,11 +185,9 @@ export const codexAdapter = {
166
185
  return true;
167
186
  }
168
187
  const source = payload.source;
169
- // 0.131+: source is an object whose keys name the subagent kind.
170
- if (source && typeof source === "object") {
171
- if (Object.prototype.hasOwnProperty.call(source, "subagent")) {
172
- return true;
173
- }
188
+ // 0.131+: source may be an object whose keys name the subagent kind.
189
+ if (isSubagentSourceObject(source)) {
190
+ return true;
174
191
  }
175
192
  // Older: source is a string enum.
176
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.16",
575
+ version: "2.12.18",
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
  }
@@ -315,9 +361,10 @@ function updateMeteringForTurn(payload, env, adapter, current, turnIndex) {
315
361
  priorWindowFloor: prior?.window_floor ?? null,
316
362
  });
317
363
  metering.writeMetering(current, record);
318
- // A valid harness-reported percentage stands on its own. Without one, a null
319
- // window means the host is undetectable for this turn, even though the record
320
- // is still persisted for observability and same-turn fail-safe agreement.
364
+ // A valid harness-reported percentage stands on its own. Without one, we fall
365
+ // back to the window: context_window_size is always numeric under the
366
+ // assumed-default ladder, so the null guard remains as a dead-man fallback only
367
+ // (the record is still persisted for observability and same-turn fail-safe agreement).
321
368
  const hasHarnessPercentage = typeof lifted.harnessPercentage === "number" &&
322
369
  Number.isFinite(lifted.harnessPercentage);
323
370
  return !hasHarnessPercentage && record.context_window_size === null;
@@ -120,28 +120,44 @@ function maybeApplyLong(candidate, source, long, enabled) {
120
120
  }
121
121
  return { candidate, source };
122
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 };
123
+ function assumedDefaultResolution(window_floor) {
124
+ if (window_floor !== null && window_floor > DEFAULT_CONTEXT_WINDOW) {
125
+ return {
126
+ window: window_floor,
127
+ source: "assumed-default+floor",
128
+ window_floor,
129
+ contradiction: false,
130
+ };
131
131
  }
132
+ return {
133
+ window: DEFAULT_CONTEXT_WINDOW,
134
+ source: "assumed-default",
135
+ window_floor,
136
+ contradiction: false,
137
+ };
138
+ }
139
+ export function resolveContextWindowDetailed(input) {
132
140
  const promptSideTokens = finiteNumber(input.promptSideTokens)
133
141
  ? input.promptSideTokens
134
142
  : null;
135
143
  const priorFloor = usablePriorFloor(input.priorWindow, input.priorWindowSource, input.priorWindowFloor);
136
144
  const observedFloor = Math.max(promptSideTokens ?? 0, priorFloor ?? 0);
137
145
  const window_floor = observedFloor > 0 ? observedFloor : null;
146
+ const normalized = normalizeModelId(input.modelId);
147
+ if (normalized === null) {
148
+ return assumedDefaultResolution(window_floor);
149
+ }
150
+ const table = loadContextWindowTable();
151
+ if (table === null) {
152
+ return assumedDefaultResolution(window_floor);
153
+ }
138
154
  if (input.harness === "claude") {
139
155
  if (!/^claude-/i.test(normalized.base)) {
140
- return { window: null, source: null, window_floor, contradiction: false };
156
+ return assumedDefaultResolution(window_floor);
141
157
  }
142
158
  const entry = table.claude[normalized.base] ?? table.family_defaults?.claude ?? null;
143
159
  if (entry === null) {
144
- return { window: null, source: null, window_floor, contradiction: false };
160
+ return assumedDefaultResolution(window_floor);
145
161
  }
146
162
  const mapped = Object.prototype.hasOwnProperty.call(table.claude, normalized.base);
147
163
  let candidate = entry.default;
@@ -154,7 +170,7 @@ export function resolveContextWindowDetailed(input) {
154
170
  source = "ratchet";
155
171
  }
156
172
  else {
157
- return { window: null, source: "contradiction", window_floor, contradiction: true };
173
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
158
174
  }
159
175
  }
160
176
  if (priorFloor !== null && priorFloor > candidate) {
@@ -163,7 +179,7 @@ export function resolveContextWindowDetailed(input) {
163
179
  source = "prior";
164
180
  }
165
181
  else {
166
- return { window: null, source: "contradiction", window_floor, contradiction: true };
182
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
167
183
  }
168
184
  }
169
185
  return { window: candidate, source, window_floor, contradiction: false };
@@ -171,7 +187,7 @@ export function resolveContextWindowDetailed(input) {
171
187
  if (input.harness === "codex") {
172
188
  const entry = table.codex[normalized.base] ?? null;
173
189
  if (entry === null) {
174
- return { window: null, source: null, window_floor, contradiction: false };
190
+ return assumedDefaultResolution(window_floor);
175
191
  }
176
192
  let candidate = entry.default;
177
193
  let source = "mapping";
@@ -182,7 +198,7 @@ export function resolveContextWindowDetailed(input) {
182
198
  source = "ratchet";
183
199
  }
184
200
  else {
185
- return { window: null, source: "contradiction", window_floor, contradiction: true };
201
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
186
202
  }
187
203
  }
188
204
  if (priorFloor !== null && priorFloor > candidate) {
@@ -191,12 +207,12 @@ export function resolveContextWindowDetailed(input) {
191
207
  source = "prior";
192
208
  }
193
209
  else {
194
- return { window: null, source: "contradiction", window_floor, contradiction: true };
210
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
195
211
  }
196
212
  }
197
213
  return { window: candidate, source, window_floor, contradiction: false };
198
214
  }
199
- return { window: null, source: null, window_floor, contradiction: false };
215
+ return assumedDefaultResolution(window_floor);
200
216
  }
201
217
  export function resolveContextWindow(harness, modelId) {
202
218
  return resolveContextWindowDetailed({ harness, modelId }).window;
@@ -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.16",
3
+ "version": "2.12.18",
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",