@heretyc/subagent-mcp 2.12.18 → 2.12.20

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
@@ -27,7 +27,7 @@ of raw file context. The main invariants are:
27
27
 
28
28
  ### What You Need First
29
29
 
30
- - Node.js 18 or newer (`node --version`)
30
+ - Node.js 20 or newer (`node --version`)
31
31
  - `claude` CLI, installed and signed in (`claude --version`)
32
32
  - `codex` CLI, installed and signed in (`codex --version`; optional if you only
33
33
  use Claude)
@@ -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,18 +2,18 @@
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
- console.log(` ${major >= 18 ? "PASS" : "FAIL"} node version — ${process.versions.node}` +
15
- (major >= 18 ? "" : " (Node >= 18 required)"));
16
- let failed = major < 18 ? 1 : 0;
14
+ console.log(` ${major >= 20 ? "PASS" : "FAIL"} node version — ${process.versions.node}` +
15
+ (major >= 20 ? "" : " (Node >= 20 required)"));
16
+ let failed = major < 20 ? 1 : 0;
17
17
  for (const r of verifyWiring(undefined, true)) {
18
18
  console.log(` ${r.ok ? "PASS" : "FAIL"} ${r.label} — ${r.detail}`);
19
19
  if (!r.ok)
@@ -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
  /**
@@ -102,9 +103,6 @@ function liftClaudeUsageFromTranscript(transcriptPath) {
102
103
  ? usage.cache_read_input_tokens
103
104
  : 0,
104
105
  },
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.
108
106
  harnessPercentage: null,
109
107
  };
110
108
  }
@@ -141,8 +139,18 @@ export const claudeAdapter = {
141
139
  const lifted = liftClaudeUsageFromTranscript(transcriptPath);
142
140
  if (lifted === null)
143
141
  return null;
142
+ const statusline = readStatuslineRecord(sessionKey(payload), typeof payload.cwd === "string" ? payload.cwd : undefined);
144
143
  return {
145
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,
146
154
  longContextHint: readClaudeLongContextHint(payload.cwd, env),
147
155
  };
148
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.18",
575
+ version: "2.12.20",
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"
@@ -1696,7 +1696,7 @@ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MOD
1696
1696
  ],
1697
1697
  };
1698
1698
  }));
1699
- // Tool 8: model-selection-mode
1699
+ // Tool 9: model-selection-mode
1700
1700
  server.tool("model-selection-mode", "Set or query per-project MODEL SELECTION MODE, which gates launch_agent's `provider`/`model`/`effort` selectors. `mode`: \"smart\" or \"user-approved-overrides\"; omit to query. \"smart\" is the DEFAULT (used whenever unset): launch_agent REJECTS any call supplying provider/model/effort and the server auto-picks the best model for the task_category. \"user-approved-overrides\" opens a 30-MINUTE window where selectors are HONORED, enforced LAZILY (reverts to smart on the next launch_agent call after the 30 min elapse); re-enabling does NOT extend an active window. HONOR-BASED, parallel to orchestration-mode: 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; a plain yes/no if neither exists). This tool CANNOT verify that authorization — never enable on your own initiative. PERSISTENCE: state keyed by cwd; both the mode and the override-window timestamp survive server restarts (remaining window is restored, not reset).", {
1701
1701
  mode: z.enum(["smart", "user-approved-overrides"]).optional(),
1702
1702
  }, withMaintenance(async (params) => {
@@ -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,6 +356,7 @@ 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,
@@ -483,6 +485,7 @@ function appendHookUpdateNotice(out, current, env) {
483
485
  export function runHook(payload, env, adapter) {
484
486
  try {
485
487
  cullHookZombies();
488
+ sweepHookState();
486
489
  if (adapter.isSubagent(payload, env)) {
487
490
  return "";
488
491
  }
@@ -143,6 +143,15 @@ export function resolveContextWindowDetailed(input) {
143
143
  const priorFloor = usablePriorFloor(input.priorWindow, input.priorWindowSource, input.priorWindowFloor);
144
144
  const observedFloor = Math.max(promptSideTokens ?? 0, priorFloor ?? 0);
145
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
+ }
146
155
  const normalized = normalizeModelId(input.modelId);
147
156
  if (normalized === null) {
148
157
  return assumedDefaultResolution(window_floor);
@@ -248,6 +257,7 @@ export function buildMeteringRecord(input) {
248
257
  priorWindow: input.priorWindow,
249
258
  priorWindowSource: input.priorWindowSource,
250
259
  priorWindowFloor: input.priorWindowFloor,
260
+ harnessContextWindow: input.harnessContextWindow,
251
261
  });
252
262
  const used_percentage = computeUsedPercentage({
253
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"));
@@ -663,8 +770,8 @@ export async function runSetup() {
663
770
  console.log(`subagent-mcp setup${DRY_RUN ? " (dry-run)" : ""}`);
664
771
  console.log(`Install root: ${INSTALL_ROOT}\n`);
665
772
  const major = Number(process.versions.node.split(".")[0]);
666
- if (major < 18) {
667
- console.error(`ERROR: Node ${process.versions.node} is too old Node >= 18 required.`);
773
+ if (major < 20) {
774
+ console.error(`ERROR: Node ${process.versions.node} is too old. Node >= 20 required.`);
668
775
  process.exit(1);
669
776
  }
670
777
  const missing = verifyInstall();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretyc/subagent-mcp",
3
- "version": "2.12.18",
3
+ "version": "2.12.20",
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",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^7.0.2"
62
62
  },
63
63
  "engines": {
64
- "node": ">=18"
64
+ "node": ">=20"
65
65
  },
66
66
  "publishConfig": {
67
67
  "registry": "https://registry.npmjs.org",