@heretyc/subagent-mcp 2.12.18 → 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 +4 -2
- package/dist/doctor.js +2 -2
- package/dist/hooks/orchestration-claude.js +12 -4
- package/dist/hooks/statusline-claude.js +126 -0
- package/dist/index.js +1 -1
- package/dist/orchestration/hook-core.js +3 -0
- package/dist/orchestration/metering.js +10 -0
- package/dist/orchestration/state-sweep.js +79 -0
- package/dist/orchestration/statusline-state.js +125 -0
- package/dist/setup.js +113 -6
- package/package.json +2 -2
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.
|
|
56
|
-
|
|
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
|
/**
|
|
@@ -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.
|
|
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,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
|
|
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.
|
|
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",
|