@heretyc/subagent-mcp 2.12.17 → 2.12.19

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 CHANGED
@@ -52,8 +52,10 @@ subagent-mcp setup
52
52
 
53
53
  Installing the package only ships the program. It does not connect anything on
54
54
  its own. `subagent-mcp setup` finds your Claude Code or Codex install and
55
- registers both the server and the per-turn orchestration hooks. Preview first
56
- with `subagent-mcp setup --dry-run`.
55
+ registers both the server and the per-turn orchestration hooks. For Claude Code
56
+ it also registers or wraps `statusLine` so the hook can read Claude's
57
+ authoritative context percentage without replacing your custom statusline.
58
+ Preview first with `subagent-mcp setup --dry-run`.
57
59
 
58
60
  ### Restart, Then Turn On The Invariant
59
61
 
package/dist/doctor.js CHANGED
@@ -2,14 +2,14 @@
2
2
  // `subagent-mcp doctor` — read-only health check for the installed addon.
3
3
  //
4
4
  // Diagnoses install completeness, vendor presence, and whether each vendor's
5
- // wiring (MCP server + hooks) points at THIS install. Doctor self-repairs
5
+ // wiring (MCP server + hooks/statusLine) points at THIS install. Doctor self-repairs
6
6
  // missing MCP registrations via vendor CLIs; use `subagent-mcp setup` for
7
7
  // config-file and hook repairs.
8
8
  //
9
9
  // Exit code: 0 = everything healthy, 1 = at least one check failed.
10
10
  import { verifyWiring } from "./setup.js";
11
11
  export async function runDoctor() {
12
- console.log("subagent-mcp doctor (checks wiring; repairs missing MCP registrations via vendor CLIs)\n");
12
+ console.log("subagent-mcp doctor (checks wiring, including Claude statusLine; repairs missing MCP registrations via vendor CLIs)\n");
13
13
  const major = Number(process.versions.node.split(".")[0]);
14
14
  console.log(` ${major >= 18 ? "PASS" : "FAIL"} node version — ${process.versions.node}` +
15
15
  (major >= 18 ? "" : " (Node >= 18 required)"));
@@ -1,6 +1,7 @@
1
1
  import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync, } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
- import { countJsonlType, runHook, TRANSCRIPT_READ_CAP, } from "../orchestration/hook-core.js";
3
+ import { countJsonlType, runHook, sessionKey, TRANSCRIPT_READ_CAP, } from "../orchestration/hook-core.js";
4
+ import { readStatuslineRecord } from "../orchestration/statusline-state.js";
4
5
  import { readClaudeLongContextHint } from "../orchestration/settings-hint.js";
5
6
  import { hasParentMarker } from "../launch-prompt.js";
6
7
  /**
@@ -26,6 +27,11 @@ const SUBAGENT_ENTRYPOINTS = new Set([
26
27
  function finiteNumber(value) {
27
28
  return typeof value === "number" && Number.isFinite(value);
28
29
  }
30
+ function isRealClaudeModel(value) {
31
+ return (typeof value === "string" &&
32
+ value.trim() !== "" &&
33
+ value.trim() !== "<synthetic>");
34
+ }
29
35
  function readTranscriptTail(transcriptPath) {
30
36
  if (!transcriptPath)
31
37
  return null;
@@ -80,12 +86,12 @@ function liftClaudeUsageFromTranscript(transcriptPath) {
80
86
  continue;
81
87
  if (msg.type !== "assistant" || !msg.message?.usage)
82
88
  continue;
83
- if (typeof msg.message.model !== "string")
84
- return null;
89
+ if (!isRealClaudeModel(msg.message.model))
90
+ continue;
85
91
  const usage = msg.message.usage;
86
92
  return {
87
93
  harness: "claude",
88
- model: msg.message.model,
94
+ model: msg.message.model.trim(),
89
95
  source_ref: transcriptPath,
90
96
  usage: {
91
97
  input: finiteNumber(usage.input_tokens) ? usage.input_tokens : 0,
@@ -133,8 +139,18 @@ export const claudeAdapter = {
133
139
  const lifted = liftClaudeUsageFromTranscript(transcriptPath);
134
140
  if (lifted === null)
135
141
  return null;
142
+ const statusline = readStatuslineRecord(sessionKey(payload), typeof payload.cwd === "string" ? payload.cwd : undefined);
136
143
  return {
137
144
  ...lifted,
145
+ harnessPercentage: typeof statusline?.used_percentage === "number" &&
146
+ Number.isFinite(statusline.used_percentage)
147
+ ? statusline.used_percentage
148
+ : null,
149
+ harnessContextWindow: typeof statusline?.context_window_size === "number" &&
150
+ Number.isFinite(statusline.context_window_size) &&
151
+ statusline.context_window_size > 0
152
+ ? statusline.context_window_size
153
+ : null,
138
154
  longContextHint: readClaudeLongContextHint(payload.cwd, env),
139
155
  };
140
156
  },
@@ -0,0 +1,126 @@
1
+ import { spawn } from "node:child_process";
2
+ import { pathToFileURL } from "node:url";
3
+ import { realpathSync } from "node:fs";
4
+ import { statuslineRecordFromPayload, writeStatuslineRecord, } from "../orchestration/statusline-state.js";
5
+ function readStdinBuffer() {
6
+ return new Promise((resolve) => {
7
+ const chunks = [];
8
+ process.stdin.on("data", (chunk) => {
9
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
10
+ });
11
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks)));
12
+ process.stdin.on("error", () => resolve(Buffer.concat(chunks)));
13
+ });
14
+ }
15
+ function parsePayload(raw) {
16
+ try {
17
+ const text = raw.toString("utf8");
18
+ if (!text.trim())
19
+ return {};
20
+ const parsed = JSON.parse(text);
21
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
22
+ ? parsed
23
+ : {};
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ function defaultLine(payload) {
30
+ const model = payload.model && typeof payload.model === "object" && !Array.isArray(payload.model)
31
+ ? payload.model.display_name
32
+ : null;
33
+ const contextWindow = payload.context_window &&
34
+ typeof payload.context_window === "object" &&
35
+ !Array.isArray(payload.context_window)
36
+ ? payload.context_window
37
+ : {};
38
+ const label = typeof model === "string" && model.trim()
39
+ ? model.trim()
40
+ : "Claude";
41
+ const pct = typeof contextWindow.used_percentage === "number" &&
42
+ Number.isFinite(contextWindow.used_percentage)
43
+ ? ` Ctx:${Math.round(contextWindow.used_percentage)}%`
44
+ : "";
45
+ return `${label}${pct}\n`;
46
+ }
47
+ function writeSideChannel(payload) {
48
+ try {
49
+ const record = statuslineRecordFromPayload(payload);
50
+ if (record !== null)
51
+ writeStatuslineRecord(payload, record);
52
+ }
53
+ catch {
54
+ // Status lines must never fail rendering.
55
+ }
56
+ }
57
+ const MAX_INNER_STDOUT_BYTES = 64 * 1024;
58
+ function delegate(command, raw, fallback) {
59
+ return new Promise((resolve) => {
60
+ const chunks = [];
61
+ let buffered = 0;
62
+ let resolved = false;
63
+ const finish = () => {
64
+ if (resolved)
65
+ return;
66
+ resolved = true;
67
+ const output = Buffer.concat(chunks, buffered);
68
+ process.stdout.write(output.toString("utf8").trim() ? output : fallback);
69
+ resolve();
70
+ };
71
+ try {
72
+ const child = spawn(command, {
73
+ shell: true,
74
+ stdio: ["pipe", "pipe", "inherit"],
75
+ });
76
+ child.stdout.on("data", (chunk) => {
77
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
78
+ if (buffered >= MAX_INNER_STDOUT_BYTES)
79
+ return;
80
+ const remaining = MAX_INNER_STDOUT_BYTES - buffered;
81
+ const stored = data.byteLength <= remaining
82
+ ? data
83
+ : data.subarray(0, remaining);
84
+ chunks.push(stored);
85
+ buffered += stored.byteLength;
86
+ });
87
+ child.on("error", () => {
88
+ if (resolved)
89
+ return;
90
+ resolved = true;
91
+ process.stdout.write(fallback);
92
+ resolve();
93
+ });
94
+ child.on("close", () => {
95
+ finish();
96
+ });
97
+ child.stdin.end(raw);
98
+ }
99
+ catch {
100
+ process.stdout.write(fallback);
101
+ resolve();
102
+ }
103
+ });
104
+ }
105
+ export async function runStatusline(argv = process.argv.slice(2)) {
106
+ const raw = await readStdinBuffer();
107
+ const payload = parsePayload(raw);
108
+ const fallback = defaultLine(payload);
109
+ writeSideChannel(payload);
110
+ const inner = (argv.length === 1 ? argv[0] : argv.join(" ")).trim();
111
+ if (inner) {
112
+ await delegate(inner, raw, fallback);
113
+ }
114
+ else {
115
+ process.stdout.write(fallback);
116
+ }
117
+ }
118
+ const isMain = process.argv[1] !== undefined &&
119
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
120
+ if (isMain) {
121
+ runStatusline()
122
+ .catch(() => {
123
+ process.stdout.write("Claude\n");
124
+ })
125
+ .finally(() => process.exit(0));
126
+ }
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.17",
575
+ version: "2.12.19",
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"
@@ -7,6 +7,7 @@ import * as reminder from "./reminder.js";
7
7
  import * as handoff from "./handoff.js";
8
8
  import * as latch from "./latch.js";
9
9
  import * as metering from "./metering.js";
10
+ import { sweepHookState } from "./state-sweep.js";
10
11
  import * as template from "./template.js";
11
12
  import { cullStaleSlots, slotDir, ZOMBIE_FORCE_GRACE_MS, } from "../concurrency.js";
12
13
  import { appendUpdateNotice, readInstalledPackageInfo, } from "./update-check.js";
@@ -355,15 +356,17 @@ function updateMeteringForTurn(payload, env, adapter, current, turnIndex) {
355
356
  ? payload.hook_event_name
356
357
  : "UserPromptSubmit",
357
358
  harnessPercentage: lifted.harnessPercentage,
359
+ harnessContextWindow: lifted.harnessContextWindow,
358
360
  longContextHint: lifted.longContextHint,
359
361
  priorWindow: prior?.context_window_size ?? null,
360
362
  priorWindowSource: prior?.window_source ?? null,
361
363
  priorWindowFloor: prior?.window_floor ?? null,
362
364
  });
363
365
  metering.writeMetering(current, record);
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.
366
+ // A valid harness-reported percentage stands on its own. Without one, we fall
367
+ // back to the window: context_window_size is always numeric under the
368
+ // assumed-default ladder, so the null guard remains as a dead-man fallback only
369
+ // (the record is still persisted for observability and same-turn fail-safe agreement).
367
370
  const hasHarnessPercentage = typeof lifted.harnessPercentage === "number" &&
368
371
  Number.isFinite(lifted.harnessPercentage);
369
372
  return !hasHarnessPercentage && record.context_window_size === null;
@@ -482,6 +485,7 @@ function appendHookUpdateNotice(out, current, env) {
482
485
  export function runHook(payload, env, adapter) {
483
486
  try {
484
487
  cullHookZombies();
488
+ sweepHookState();
485
489
  if (adapter.isSubagent(payload, env)) {
486
490
  return "";
487
491
  }
@@ -120,28 +120,53 @@ 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
+ if (finiteNumber(input.harnessContextWindow) &&
147
+ input.harnessContextWindow > 0) {
148
+ return {
149
+ window: input.harnessContextWindow,
150
+ source: "harness",
151
+ window_floor,
152
+ contradiction: false,
153
+ };
154
+ }
155
+ const normalized = normalizeModelId(input.modelId);
156
+ if (normalized === null) {
157
+ return assumedDefaultResolution(window_floor);
158
+ }
159
+ const table = loadContextWindowTable();
160
+ if (table === null) {
161
+ return assumedDefaultResolution(window_floor);
162
+ }
138
163
  if (input.harness === "claude") {
139
164
  if (!/^claude-/i.test(normalized.base)) {
140
- return { window: null, source: null, window_floor, contradiction: false };
165
+ return assumedDefaultResolution(window_floor);
141
166
  }
142
167
  const entry = table.claude[normalized.base] ?? table.family_defaults?.claude ?? null;
143
168
  if (entry === null) {
144
- return { window: null, source: null, window_floor, contradiction: false };
169
+ return assumedDefaultResolution(window_floor);
145
170
  }
146
171
  const mapped = Object.prototype.hasOwnProperty.call(table.claude, normalized.base);
147
172
  let candidate = entry.default;
@@ -154,7 +179,7 @@ export function resolveContextWindowDetailed(input) {
154
179
  source = "ratchet";
155
180
  }
156
181
  else {
157
- return { window: null, source: "contradiction", window_floor, contradiction: true };
182
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
158
183
  }
159
184
  }
160
185
  if (priorFloor !== null && priorFloor > candidate) {
@@ -163,7 +188,7 @@ export function resolveContextWindowDetailed(input) {
163
188
  source = "prior";
164
189
  }
165
190
  else {
166
- return { window: null, source: "contradiction", window_floor, contradiction: true };
191
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
167
192
  }
168
193
  }
169
194
  return { window: candidate, source, window_floor, contradiction: false };
@@ -171,7 +196,7 @@ export function resolveContextWindowDetailed(input) {
171
196
  if (input.harness === "codex") {
172
197
  const entry = table.codex[normalized.base] ?? null;
173
198
  if (entry === null) {
174
- return { window: null, source: null, window_floor, contradiction: false };
199
+ return assumedDefaultResolution(window_floor);
175
200
  }
176
201
  let candidate = entry.default;
177
202
  let source = "mapping";
@@ -182,7 +207,7 @@ export function resolveContextWindowDetailed(input) {
182
207
  source = "ratchet";
183
208
  }
184
209
  else {
185
- return { window: null, source: "contradiction", window_floor, contradiction: true };
210
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
186
211
  }
187
212
  }
188
213
  if (priorFloor !== null && priorFloor > candidate) {
@@ -191,12 +216,12 @@ export function resolveContextWindowDetailed(input) {
191
216
  source = "prior";
192
217
  }
193
218
  else {
194
- return { window: null, source: "contradiction", window_floor, contradiction: true };
219
+ return { window: entry.long ?? entry.default, source: "contradiction", window_floor, contradiction: true };
195
220
  }
196
221
  }
197
222
  return { window: candidate, source, window_floor, contradiction: false };
198
223
  }
199
- return { window: null, source: null, window_floor, contradiction: false };
224
+ return assumedDefaultResolution(window_floor);
200
225
  }
201
226
  export function resolveContextWindow(harness, modelId) {
202
227
  return resolveContextWindowDetailed({ harness, modelId }).window;
@@ -232,6 +257,7 @@ export function buildMeteringRecord(input) {
232
257
  priorWindow: input.priorWindow,
233
258
  priorWindowSource: input.priorWindowSource,
234
259
  priorWindowFloor: input.priorWindowFloor,
260
+ harnessContextWindow: input.harnessContextWindow,
235
261
  });
236
262
  const used_percentage = computeUsedPercentage({
237
263
  context_window_size: resolution.window,
@@ -0,0 +1,79 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { LATCH_REV } from "./latch.js";
4
+ import { stateDir } from "./marker.js";
5
+ import { STATUSLINE_TTL_MS } from "./statusline-state.js";
6
+ export const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
7
+ function finiteNumber(value) {
8
+ return typeof value === "number" && Number.isFinite(value);
9
+ }
10
+ function readJson(path) {
11
+ try {
12
+ return JSON.parse(readFileSync(path, "utf8"));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function staleByRecordOrMtime(path, now) {
19
+ const parsed = readJson(path);
20
+ const updated = parsed && typeof parsed === "object"
21
+ ? parsed.updated_at
22
+ : null;
23
+ if (finiteNumber(updated))
24
+ return now - updated > STATUSLINE_TTL_MS;
25
+ try {
26
+ return now - statSync(path).mtimeMs > STATUSLINE_TTL_MS;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function obsoleteLatch(path) {
33
+ const parsed = readJson(path);
34
+ if (!parsed || typeof parsed !== "object")
35
+ return true;
36
+ const rev = parsed.rev;
37
+ return !Number.isInteger(rev) || rev < LATCH_REV;
38
+ }
39
+ function deleteBestEffort(path) {
40
+ try {
41
+ unlinkSync(path);
42
+ }
43
+ catch {
44
+ // Best-effort sweep. Hooks must never fail host turns.
45
+ }
46
+ }
47
+ export function sweepHookState(stateDirOverride = stateDir, now = Date.now()) {
48
+ try {
49
+ const stamp = join(stateDirOverride, "sweep.stamp");
50
+ if (existsSync(stamp)) {
51
+ try {
52
+ if (now - statSync(stamp).mtimeMs < SWEEP_INTERVAL_MS)
53
+ return;
54
+ }
55
+ catch {
56
+ // If the stamp cannot be read, try a sweep and rewrite it.
57
+ }
58
+ }
59
+ for (const name of readdirSync(stateDirOverride)) {
60
+ const path = join(stateDirOverride, name);
61
+ if (/^latch-[0-9a-f]{16}\.json$/i.test(name)) {
62
+ if (obsoleteLatch(path))
63
+ deleteBestEffort(path);
64
+ }
65
+ else if (/^(ctx|sl)-[0-9a-f]{16}\.json$/i.test(name)) {
66
+ if (staleByRecordOrMtime(path, now))
67
+ deleteBestEffort(path);
68
+ }
69
+ else if (/^sl-cwd-[0-9a-f]{16}\.json$/i.test(name)) {
70
+ if (staleByRecordOrMtime(path, now))
71
+ deleteBestEffort(path);
72
+ }
73
+ }
74
+ writeFileSync(stamp, String(now), { encoding: "utf8", mode: 0o600 });
75
+ }
76
+ catch {
77
+ // Best-effort only.
78
+ }
79
+ }
@@ -0,0 +1,125 @@
1
+ import { existsSync, mkdirSync, readFileSync, } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { atomicWriteJson } from "./atomic-write.js";
4
+ import { cwdHash, hashKey, stateDir } from "./marker.js";
5
+ export const STATUSLINE_TTL_MS = 24 * 60 * 60 * 1000;
6
+ function finiteNumber(value) {
7
+ return typeof value === "number" && Number.isFinite(value);
8
+ }
9
+ function normalizePathKey(pathValue) {
10
+ let p = resolve(pathValue);
11
+ p = p.replace(/\\/g, "/");
12
+ if (process.platform === "win32") {
13
+ p = p.toLowerCase();
14
+ }
15
+ if (p.length > 1 && p.endsWith("/")) {
16
+ p = p.slice(0, -1);
17
+ }
18
+ return p;
19
+ }
20
+ export function statuslineSessionKey(payload) {
21
+ if (typeof payload.session_id === "string" && payload.session_id.length > 0) {
22
+ return payload.session_id;
23
+ }
24
+ if (typeof payload.transcript_path === "string" &&
25
+ payload.transcript_path.length > 0) {
26
+ return "tp-" + hashKey(normalizePathKey(payload.transcript_path));
27
+ }
28
+ return undefined;
29
+ }
30
+ export function statuslinePathForSession(sessionKey, stateDirOverride = stateDir) {
31
+ return join(stateDirOverride, "sl-" + hashKey(sessionKey) + ".json");
32
+ }
33
+ export function statuslinePathForCwd(cwd, stateDirOverride = stateDir) {
34
+ return join(stateDirOverride, "sl-cwd-" + cwdHash(cwd) + ".json");
35
+ }
36
+ function pathForPayload(payload, stateDirOverride = stateDir) {
37
+ const key = statuslineSessionKey(payload);
38
+ if (key !== undefined)
39
+ return statuslinePathForSession(key, stateDirOverride);
40
+ return typeof payload.cwd === "string" && payload.cwd.length > 0
41
+ ? statuslinePathForCwd(payload.cwd, stateDirOverride)
42
+ : null;
43
+ }
44
+ function isStatuslineRecord(value) {
45
+ if (!value || typeof value !== "object")
46
+ return false;
47
+ const record = value;
48
+ return record.source === "statusline" && finiteNumber(record.updated_at);
49
+ }
50
+ export function writeStatuslineRecord(payload, record, stateDirOverride = stateDir) {
51
+ try {
52
+ const path = pathForPayload(payload, stateDirOverride);
53
+ if (path === null)
54
+ return false;
55
+ mkdirSync(stateDirOverride, { recursive: true, mode: 0o700 });
56
+ atomicWriteJson(path, record, { encoding: "utf8", mode: 0o600 });
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ function readFresh(path, now) {
64
+ try {
65
+ if (!existsSync(path))
66
+ return null;
67
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
68
+ if (!isStatuslineRecord(parsed))
69
+ return null;
70
+ if (now - parsed.updated_at > STATUSLINE_TTL_MS)
71
+ return null;
72
+ return parsed;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ export function readStatuslineRecord(sessionKey, cwd, stateDirOverride = stateDir, now = Date.now()) {
79
+ if (sessionKey !== undefined) {
80
+ const keyed = readFresh(statuslinePathForSession(sessionKey, stateDirOverride), now);
81
+ if (keyed !== null)
82
+ return keyed;
83
+ }
84
+ if (cwd !== undefined && cwd.length > 0) {
85
+ return readFresh(statuslinePathForCwd(cwd, stateDirOverride), now);
86
+ }
87
+ return null;
88
+ }
89
+ export function statuslineRecordFromPayload(payload, now = Date.now()) {
90
+ const contextWindow = payload.context_window &&
91
+ typeof payload.context_window === "object" &&
92
+ !Array.isArray(payload.context_window)
93
+ ? payload.context_window
94
+ : {};
95
+ const currentUsage = contextWindow.current_usage &&
96
+ typeof contextWindow.current_usage === "object" &&
97
+ !Array.isArray(contextWindow.current_usage)
98
+ ? contextWindow.current_usage
99
+ : {};
100
+ const used = contextWindow.used_percentage;
101
+ const size = contextWindow.context_window_size;
102
+ if (!finiteNumber(used) && !finiteNumber(size))
103
+ return null;
104
+ return {
105
+ session_id: typeof payload.session_id === "string" ? payload.session_id : null,
106
+ used_percentage: finiteNumber(used) ? used : null,
107
+ context_window_size: finiteNumber(size) ? size : null,
108
+ usage: {
109
+ input: finiteNumber(currentUsage.input_tokens)
110
+ ? currentUsage.input_tokens
111
+ : 0,
112
+ output: finiteNumber(currentUsage.output_tokens)
113
+ ? currentUsage.output_tokens
114
+ : 0,
115
+ cache_creation: finiteNumber(currentUsage.cache_creation_input_tokens)
116
+ ? currentUsage.cache_creation_input_tokens
117
+ : 0,
118
+ cache_read: finiteNumber(currentUsage.cache_read_input_tokens)
119
+ ? currentUsage.cache_read_input_tokens
120
+ : 0,
121
+ },
122
+ updated_at: now,
123
+ source: "statusline",
124
+ };
125
+ }
package/dist/setup.js CHANGED
@@ -16,11 +16,13 @@
16
16
  // - Every config file is backed up before its first edit.
17
17
  // - Failures never abort the run: they are collected and reported at the end
18
18
  // with a copy-paste repair prompt the user can hand to Claude/Codex.
19
- import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, } from "node:fs";
19
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, statSync, } from "node:fs";
20
20
  import { homedir } from "node:os";
21
21
  import { join, dirname, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { execFileSync, execSync } from "node:child_process";
24
+ import { stateDir } from "./orchestration/marker.js";
25
+ import { STATUSLINE_TTL_MS } from "./orchestration/statusline-state.js";
24
26
  const cliArgs = process.argv.slice(3); // argv[2]='setup', flags start at [3]
25
27
  const DRY_RUN = cliArgs.includes("--dry-run");
26
28
  export const SERVER_NAME = "subagent-mcp";
@@ -35,9 +37,49 @@ export function serverPaths(root = INSTALL_ROOT) {
35
37
  server: `${f}/dist/index.js`,
36
38
  claudeHook: `${f}/dist/hooks/orchestration-claude.js`,
37
39
  claudePreToolHook: `${f}/dist/hooks/orchestration-claude-pretool.js`,
40
+ claudeStatuslineHook: `${f}/dist/hooks/statusline-claude.js`,
38
41
  codexHook: `${f}/dist/hooks/orchestration-codex.js`,
39
42
  };
40
43
  }
44
+ function statuslineCommand(shimPath, innerCommand = "") {
45
+ const inner = innerCommand.trim();
46
+ return `node "${shimPath}"${inner ? ` ${quoteStatuslineInnerArg(inner)}` : ""}`;
47
+ }
48
+ function quoteStatuslineInnerArg(command) {
49
+ if (process.platform === "win32") {
50
+ return JSON.stringify(command);
51
+ }
52
+ return `'${command.replace(/'/g, "'\\''")}'`;
53
+ }
54
+ function extractStatuslineInner(command) {
55
+ const marker = "statusline-claude.js";
56
+ const idx = command.indexOf(marker);
57
+ if (idx < 0)
58
+ return null;
59
+ let restStart = idx + marker.length;
60
+ if (command[restStart] === "\"")
61
+ restStart++;
62
+ const rest = command.slice(restStart).trim();
63
+ return unquoteStatuslineInnerArg(rest);
64
+ }
65
+ function unquoteStatuslineInnerArg(arg) {
66
+ if (!arg)
67
+ return arg;
68
+ if (process.platform === "win32" && arg.startsWith("\"")) {
69
+ try {
70
+ const parsed = JSON.parse(arg);
71
+ if (typeof parsed === "string")
72
+ return parsed;
73
+ }
74
+ catch {
75
+ return arg;
76
+ }
77
+ }
78
+ if (process.platform !== "win32" && arg.startsWith("'") && arg.endsWith("'")) {
79
+ return arg.slice(1, -1).replace(/'\\''/g, "'");
80
+ }
81
+ return arg;
82
+ }
41
83
  /**
42
84
  * Pure-node PATH lookup. `where`/`which` are not guaranteed to exist (minimal
43
85
  * containers, stripped distros), so scan PATH ourselves. On win32, PATHEXT
@@ -67,7 +109,7 @@ export function findOnPath(cmd, env = process.env, platform = process.platform)
67
109
  * orchestration-claude.js at any OTHER path/shape -> repaired (rewritten to the
68
110
  * canonical exec form). Absent -> added. Unrelated hooks are never touched.
69
111
  */
70
- export function reconcileClaudeSettings(s, hookPath, preToolHookPath = hookPath.replace(/orchestration-claude\.js$/, "orchestration-claude-pretool.js")) {
112
+ export function reconcileClaudeSettings(s, hookPath, preToolHookPath = hookPath.replace(/orchestration-claude\.js$/, "orchestration-claude-pretool.js"), statuslineHookPath = hookPath.replace(/orchestration-claude\.js$/, "statusline-claude.js")) {
71
113
  const hooksBlock = (s.hooks ?? {});
72
114
  s.hooks = hooksBlock;
73
115
  const reconcile = (event, scriptName, desired) => {
@@ -103,12 +145,40 @@ export function reconcileClaudeSettings(s, hookPath, preToolHookPath = hookPath.
103
145
  args: [preToolHookPath],
104
146
  timeout: 5,
105
147
  });
106
- const status = prompt.status === "repaired" || pretool.status === "repaired"
148
+ const statusline = reconcileClaudeStatusLine(s, statuslineHookPath);
149
+ const status = prompt.status === "repaired" || pretool.status === "repaired" || statusline.status === "repaired"
107
150
  ? "repaired"
108
- : prompt.status === "added" || pretool.status === "added"
151
+ : prompt.status === "added" || pretool.status === "added" || statusline.status === "added"
109
152
  ? "added"
110
153
  : "ok";
111
- return { changed: prompt.changed || pretool.changed, status };
154
+ return { changed: prompt.changed || pretool.changed || statusline.changed, status };
155
+ }
156
+ export function reconcileClaudeStatusLine(s, statuslineHookPath) {
157
+ const current = s.statusLine;
158
+ const currentCommand = current && typeof current === "object" && !Array.isArray(current) &&
159
+ typeof current.command === "string"
160
+ ? current.command
161
+ : typeof current === "string"
162
+ ? current
163
+ : null;
164
+ const inner = currentCommand !== null ? extractStatuslineInner(currentCommand) : null;
165
+ const desired = {
166
+ type: "command",
167
+ command: statuslineCommand(statuslineHookPath, inner ?? currentCommand ?? ""),
168
+ };
169
+ if (currentCommand === null) {
170
+ s.statusLine = desired;
171
+ return { changed: true, status: "added" };
172
+ }
173
+ if (current &&
174
+ typeof current === "object" &&
175
+ !Array.isArray(current) &&
176
+ current.type === desired.type &&
177
+ current.command === desired.command) {
178
+ return { changed: false, status: "ok" };
179
+ }
180
+ s.statusLine = desired;
181
+ return { changed: true, status: "repaired" };
112
182
  }
113
183
  /**
114
184
  * Reconcile the user-scope MCP server entry in a parsed ~/.claude.json.
@@ -230,6 +300,7 @@ export function verifyInstall(root = INSTALL_ROOT) {
230
300
  "dist/global-subagent-mcp-config.jsonc",
231
301
  "dist/hooks/orchestration-claude.js",
232
302
  "dist/hooks/orchestration-claude-pretool.js",
303
+ "dist/hooks/statusline-claude.js",
233
304
  "dist/hooks/orchestration-codex.js",
234
305
  "directives/carryover-claude.md",
235
306
  "directives/carryover-codex.md",
@@ -382,7 +453,8 @@ function repairPromptFor(vendor, problem) {
382
453
  `the global bin shim "subagent-mcp" (use 'claude mcp add subagent-mcp subagent-mcp -s user' or edit the mcpServers ` +
383
454
  `key in ~/.claude.json), and (2) ensure ~/.claude/settings.json has ` +
384
455
  `hooks.UserPromptSubmit -> {type:"command", command:"node", args:["${p.claudeHook}"]} and ` +
385
- `hooks.PreToolUse -> {type:"command", command:"node", args:["${p.claudePreToolHook}"], timeout:5}. ` +
456
+ `hooks.PreToolUse -> {type:"command", command:"node", args:["${p.claudePreToolHook}"], timeout:5}, and ` +
457
+ `statusLine -> {type:"command", command:"node \\"${p.claudeStatuslineHook}\\""}. ` +
386
458
  `Back up any file before editing it.`);
387
459
  }
388
460
  return (`subagent-mcp setup hit a problem on my machine: ${problem}. ` +
@@ -563,6 +635,21 @@ export function registrationDetail(registered, attemptedRepair) {
563
635
  ? "not registered; CLI repair failed"
564
636
  : "not registered — run: subagent-mcp doctor";
565
637
  }
638
+ export function hasRecentStatuslineSignal(stateDirOverride = stateDir, now = Date.now()) {
639
+ try {
640
+ for (const name of readdirSync(stateDirOverride)) {
641
+ if (!/^sl-(?:cwd-)?[0-9a-f]{16}\.json$/i.test(name))
642
+ continue;
643
+ const stat = statSync(join(stateDirOverride, name));
644
+ if (now - stat.mtimeMs <= STATUSLINE_TTL_MS)
645
+ return true;
646
+ }
647
+ }
648
+ catch {
649
+ return false;
650
+ }
651
+ return false;
652
+ }
566
653
  function checkCliRegistration(cli, addArgs, repair, deps = defaultExecDeps) {
567
654
  let registered = registeredViaCli(cli, deps);
568
655
  let attemptedRepair = false;
@@ -599,6 +686,16 @@ export function verifyWiring(root = INSTALL_ROOT, repair = false) {
599
686
  ok: hk.status === "ok",
600
687
  detail: hk.status === "ok" ? "wired" : `${hk.status === "repaired" ? "stale path" : "not wired"} - run: subagent-mcp setup`,
601
688
  });
689
+ const sl = reconcileClaudeStatusLine(sj, p.claudeStatuslineHook);
690
+ results.push({
691
+ label: "claude: statusLine",
692
+ ok: sl.status === "ok",
693
+ detail: sl.status === "ok"
694
+ ? hasRecentStatuslineSignal()
695
+ ? "wired; signal live"
696
+ : "wired; waiting for Claude statusLine signal"
697
+ : `${sl.status === "repaired" ? "stale path" : "not wired"} - run: subagent-mcp setup`,
698
+ });
602
699
  }
603
700
  else if (hasClaudeConfig) {
604
701
  const cj = readJson(join(home, ".claude.json"), {});
@@ -615,6 +712,16 @@ export function verifyWiring(root = INSTALL_ROOT, repair = false) {
615
712
  ok: hk.status === "ok",
616
713
  detail: hk.status === "ok" ? "wired" : `${hk.status === "repaired" ? "stale path" : "not wired"} - run: subagent-mcp setup`,
617
714
  });
715
+ const sl = reconcileClaudeStatusLine(sj, p.claudeStatuslineHook);
716
+ results.push({
717
+ label: "claude: statusLine",
718
+ ok: sl.status === "ok",
719
+ detail: sl.status === "ok"
720
+ ? hasRecentStatuslineSignal()
721
+ ? "wired; signal live"
722
+ : "wired; waiting for Claude statusLine signal"
723
+ : `${sl.status === "repaired" ? "stale path" : "not wired"} - run: subagent-mcp setup`,
724
+ });
618
725
  }
619
726
  const hasCodexCli = findOnPath("codex") !== null;
620
727
  const hasCodex = hasCodexCli || existsSync(join(home, ".codex"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.12.17",
3
+ "version": "2.12.19",
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 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"
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/statusline-claude.test.mjs && node test/orchestration-sweep.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",