agent-coord-mcp 0.18.0 → 0.19.1
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 +13 -1
- package/dist/server.js +127 -9
- package/dist/server.js.map +1 -1
- package/dist/store.js +33 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +24 -5
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/registry.js +69 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/transport.js +134 -25
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +28 -8
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +36 -4
- package/dist/work.js.map +1 -1
- package/hooks/submit.mjs +136 -14
- package/hooks/tmux-pusher.mjs +6 -0
- package/package.json +1 -1
- package/scripts/check-self-dependency.mjs +22 -10
- package/scripts/check-test-count.mjs +1 -1
- package/scripts/coord-pusher.mjs +39 -9
- package/src/server.ts +131 -9
- package/src/store.ts +45 -1
- package/src/tools/admin.ts +22 -5
- package/src/tools/registry.ts +96 -0
- package/src/tools/transport.ts +154 -23
- package/src/tools/work.ts +28 -7
- package/src/work.ts +64 -7
package/hooks/submit.mjs
CHANGED
|
@@ -64,6 +64,27 @@ export const PROMPT_PATTERN = () => envStr("AGENT_COORD_PROMPT_PATTERN", "^\\s*[
|
|
|
64
64
|
export const PLACEHOLDER_PATTERN = () =>
|
|
65
65
|
envStr("AGENT_COORD_PLACEHOLDER_PATTERN", '^(Try ".*"|Press up to edit queued messages|)$');
|
|
66
66
|
|
|
67
|
+
// GHOST TEXT — the ONE place the rendering assumption lives.
|
|
68
|
+
//
|
|
69
|
+
// Claude Code renders a session-derived SUGGESTED next prompt ("ghost text")
|
|
70
|
+
// inside the input box while idle: `❯ ` + ESC[2m + suggestion + ESC[0m. It is
|
|
71
|
+
// chrome, not content: nobody typed it, the first keystroke replaces it, and
|
|
72
|
+
// it never reaches scrollback. To a plain `capture-pane` it is byte-for-byte
|
|
73
|
+
// indistinguishable from a real draft — which made this guard refuse control
|
|
74
|
+
// commands against EMPTY inputs fleet-wide (2026-07-29: three live refusals,
|
|
75
|
+
// every quoted "draft" was the suggestion; /clear and /compact were
|
|
76
|
+
// undeliverable to exactly the agents they were built for). The placeholder
|
|
77
|
+
// regex above cannot help: the suggestion is derived from session state, so
|
|
78
|
+
// its space of values is unbounded — no content pattern can enumerate it.
|
|
79
|
+
// Styling is the only channel that carries the distinction, hence
|
|
80
|
+
// `capture-pane -e` below.
|
|
81
|
+
//
|
|
82
|
+
// SGR 2 (dim/faint) is Claude Code's CURRENT rendering of the suggestion — a
|
|
83
|
+
// per-harness rendering detail, not a protocol guarantee. If a harness styles
|
|
84
|
+
// suggestions differently, or a release changes it, override or edit HERE and
|
|
85
|
+
// nowhere else.
|
|
86
|
+
export const GHOST_TEXT_SGR = () => envStr("AGENT_COORD_GHOST_TEXT_SGR", "2");
|
|
87
|
+
|
|
67
88
|
function envStr(name, fallback) {
|
|
68
89
|
const v = process.env[name];
|
|
69
90
|
return v === undefined ? fallback : v;
|
|
@@ -80,6 +101,65 @@ function squash(s) {
|
|
|
80
101
|
return String(s ?? "").replace(/\s+/g, " ").trim();
|
|
81
102
|
}
|
|
82
103
|
|
|
104
|
+
// Terminal escape sequences as they appear in `capture-pane -e` output:
|
|
105
|
+
// CSI (colors/attributes, cursor) and OSC (hyperlinks, titles). Anything not
|
|
106
|
+
// matched stays in the text — unrecognized bytes read as content, and content
|
|
107
|
+
// fails toward refusing, never toward delivering.
|
|
108
|
+
const CSI_RE = /\x1b\[[0-9;:?]*[A-Za-z]/g;
|
|
109
|
+
const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
110
|
+
|
|
111
|
+
export function stripAnsi(s) {
|
|
112
|
+
return String(s ?? "").replace(OSC_RE, "").replace(CSI_RE, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Split ONE styled line into real (typed) and ghost (suggestion) text by
|
|
116
|
+
// walking its SGR state. Dim-attributed spans are ghost; everything else —
|
|
117
|
+
// including any styling we do not recognize — is real. See GHOST_TEXT_SGR for
|
|
118
|
+
// why dim, and for the asymmetry that makes "unrecognized = real" the safe
|
|
119
|
+
// default.
|
|
120
|
+
export function partitionStyledLine(styledLine) {
|
|
121
|
+
const ghostAttr = GHOST_TEXT_SGR();
|
|
122
|
+
const src = String(styledLine ?? "");
|
|
123
|
+
let real = "";
|
|
124
|
+
let ghost = "";
|
|
125
|
+
let dim = false;
|
|
126
|
+
let i = 0;
|
|
127
|
+
while (i < src.length) {
|
|
128
|
+
if (src[i] === "\x1b") {
|
|
129
|
+
const rest = src.slice(i);
|
|
130
|
+
const om = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.exec(rest);
|
|
131
|
+
if (om) {
|
|
132
|
+
i += om[0].length;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const cm = /^\x1b\[([0-9;:?]*)([A-Za-z])/.exec(rest);
|
|
136
|
+
if (cm) {
|
|
137
|
+
if (cm[2] === "m") {
|
|
138
|
+
// SGR: parameters separated by ; or :. Empty list means reset.
|
|
139
|
+
const params = cm[1] === "" ? ["0"] : cm[1].split(/[;:]/);
|
|
140
|
+
for (const p of params) {
|
|
141
|
+
if (p === "0" || p === "") dim = false; // full reset
|
|
142
|
+
else if (p === "22") dim = false; // "normal intensity" clears dim
|
|
143
|
+
else if (p === ghostAttr) dim = true;
|
|
144
|
+
// 38;5;N color runs share the list; a bare "5"/"2" inside a color
|
|
145
|
+
// spec could false-trigger — handle the extended-color form:
|
|
146
|
+
if (p === "38" || p === "48") break; // rest of list is a color spec
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
i += cm[0].length;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Lone ESC we do not understand: drop the ESC byte, keep going.
|
|
153
|
+
i += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (dim) ghost += src[i];
|
|
157
|
+
else real += src[i];
|
|
158
|
+
i += 1;
|
|
159
|
+
}
|
|
160
|
+
return { real, ghost };
|
|
161
|
+
}
|
|
162
|
+
|
|
83
163
|
// Is `payload` still sitting in the pane's INPUT LINE?
|
|
84
164
|
//
|
|
85
165
|
// It asks the input line specifically, and nothing else. The first version
|
|
@@ -109,32 +189,49 @@ export function stillInInput(paneText, payload) {
|
|
|
109
189
|
|
|
110
190
|
// Read the pane's input state: is the TUI busy, and is there unsent text?
|
|
111
191
|
//
|
|
112
|
-
//
|
|
192
|
+
// Accepts BOTH plain and styled (`capture-pane -e`) text — on styled input the
|
|
193
|
+
// ghost suggestion is partitioned out of `draft` (see GHOST_TEXT_SGR); plain
|
|
194
|
+
// text has no style layer, so everything on the input line reads as content,
|
|
195
|
+
// which is the conservative side. `ghost` and `styledInputLine` are null on
|
|
196
|
+
// plain input.
|
|
197
|
+
//
|
|
198
|
+
// `null` for busy/draft means UNKNOWN — the pane could not be captured, or
|
|
113
199
|
// this TUI does not look like the one we know how to read. Unknown is never
|
|
114
200
|
// treated as "fine": callers either proceed and fall back to verify-by-absence
|
|
115
201
|
// or report the uncertainty, but they never upgrade it to a confirmation.
|
|
116
202
|
export function readPaneState(paneText) {
|
|
117
|
-
if (paneText === null || paneText === undefined)
|
|
203
|
+
if (paneText === null || paneText === undefined) {
|
|
204
|
+
return { busy: null, draft: null, ghost: null, styledInputLine: null };
|
|
205
|
+
}
|
|
118
206
|
const text = String(paneText);
|
|
119
207
|
const busyRe = BUSY_PATTERN();
|
|
120
|
-
|
|
208
|
+
// Busy markers live in the status chrome; match on the de-styled text.
|
|
209
|
+
const busy = busyRe ? new RegExp(busyRe).test(stripAnsi(text)) : null;
|
|
121
210
|
|
|
122
211
|
const promptRe = PROMPT_PATTERN();
|
|
123
212
|
let draft = null;
|
|
213
|
+
let ghost = null;
|
|
214
|
+
let styledInputLine = null;
|
|
124
215
|
if (promptRe) {
|
|
125
216
|
const re = new RegExp(promptRe);
|
|
126
|
-
const lines = text.split("\n")
|
|
217
|
+
const lines = text.split("\n");
|
|
127
218
|
// The LAST prompt line is the live input; earlier ones are history.
|
|
128
219
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
129
|
-
|
|
220
|
+
// Partition FIRST: the prompt char renders undimmed, so it stays in
|
|
221
|
+
// `real` and the prompt regex matches the de-styled real text. Ghost
|
|
222
|
+
// spans never reach the draft no matter what they contain.
|
|
223
|
+
const { real, ghost: ghostText } = partitionStyledLine(lines[i]);
|
|
224
|
+
const m = re.exec(real.replace(/\s+$/, ""));
|
|
130
225
|
if (!m) continue;
|
|
131
226
|
const content = (m[1] ?? "").trim();
|
|
132
227
|
const placeholder = PLACEHOLDER_PATTERN();
|
|
133
228
|
draft = placeholder && new RegExp(placeholder).test(content) ? "" : content;
|
|
229
|
+
ghost = squash(ghostText) || null;
|
|
230
|
+
styledInputLine = lines[i];
|
|
134
231
|
break;
|
|
135
232
|
}
|
|
136
233
|
}
|
|
137
|
-
return { busy, draft };
|
|
234
|
+
return { busy, draft, ghost, styledInputLine };
|
|
138
235
|
}
|
|
139
236
|
|
|
140
237
|
// Submit a CONTROL command (/clear, /compact) — the path that must actually
|
|
@@ -146,12 +243,22 @@ export function readPaneState(paneText) {
|
|
|
146
243
|
// delivery:"confirmed". Waiting briefly and then declining is slower and
|
|
147
244
|
// louder — deliberately, because a control command that silently becomes a
|
|
148
245
|
// chat message is worse than one that says it did not run.
|
|
246
|
+
// The ONE place a pane is captured for guard/verify decisions. `-e` is
|
|
247
|
+
// load-bearing: it keeps the style layer, the only channel that distinguishes
|
|
248
|
+
// a real draft from the TUI's ghost suggestion (see GHOST_TEXT_SGR). Without
|
|
249
|
+
// it every ghost reads as a draft again — and the SUITE CANNOT NOTICE,
|
|
250
|
+
// because the parser tests inject styled text below this call. That is the
|
|
251
|
+
// scriptMtime family of failure: the subject of a check silently disabling
|
|
252
|
+
// it. A source lock in control-submit.test.mjs pins the flag here and counts
|
|
253
|
+
// capture sites, so a new capture call added without `-e` trips it too.
|
|
254
|
+
function captureStyled(run, target) {
|
|
255
|
+
const cap = run(["capture-pane", "-e", "-p", "-t", target]);
|
|
256
|
+
return cap.status === 0 ? String(cap.stdout ?? "") : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
149
259
|
export async function submitControl(deps, payload) {
|
|
150
260
|
const { run, target } = deps;
|
|
151
|
-
const capture = () =>
|
|
152
|
-
const cap = run(["capture-pane", "-p", "-t", target]);
|
|
153
|
-
return cap.status === 0 ? String(cap.stdout ?? "") : null;
|
|
154
|
-
};
|
|
261
|
+
const capture = () => captureStyled(run, target);
|
|
155
262
|
|
|
156
263
|
const idleBudget = CONTROL_IDLE_WAIT_MS();
|
|
157
264
|
const deadline = Date.now() + idleBudget;
|
|
@@ -172,6 +279,14 @@ export async function submitControl(deps, payload) {
|
|
|
172
279
|
};
|
|
173
280
|
}
|
|
174
281
|
if (state.draft) {
|
|
282
|
+
// ASYMMETRY — do not "fix" this branch toward delivering. A false REFUSAL
|
|
283
|
+
// costs one control command: retryable, visible, and self-diagnosing via
|
|
284
|
+
// the styled quote below. A false DELIVERY types keystrokes into someone's
|
|
285
|
+
// real unsent text and submits the concatenation to their model — the
|
|
286
|
+
// exact harm this guard exists to prevent. A harness whose ghost text is
|
|
287
|
+
// NOT dim-styled therefore lands here and refuses; that is the intended
|
|
288
|
+
// conservative direction, and the remedy is teaching GHOST_TEXT_SGR its
|
|
289
|
+
// rendering, never loosening this check.
|
|
175
290
|
return {
|
|
176
291
|
submitted: false,
|
|
177
292
|
verified: true,
|
|
@@ -179,7 +294,10 @@ export async function submitControl(deps, payload) {
|
|
|
179
294
|
attempts: 0,
|
|
180
295
|
reason:
|
|
181
296
|
`pane '${target}' has unsent text in its input (${JSON.stringify(state.draft.slice(0, 40))}…) — not pasted. ` +
|
|
182
|
-
`The command would have been appended to that draft and sent to the model as an ordinary message
|
|
297
|
+
`The command would have been appended to that draft and sent to the model as an ordinary message. ` +
|
|
298
|
+
`Rule: non-dim input-line content is a draft; dim (SGR ${GHOST_TEXT_SGR()}) spans are the TUI's ghost ` +
|
|
299
|
+
`suggestion and are ignored (AGENT_COORD_GHOST_TEXT_SGR overrides). ` +
|
|
300
|
+
`Styled input line: ${JSON.stringify(String(state.styledInputLine ?? "").slice(0, 160))}`,
|
|
183
301
|
};
|
|
184
302
|
}
|
|
185
303
|
return pasteAndSubmit(deps, payload, { bracketed: false, verify: true });
|
|
@@ -259,9 +377,13 @@ async function pollUntilGone(deps, payload) {
|
|
|
259
377
|
const deadline = Date.now() + VERIFY_TIMEOUT_MS();
|
|
260
378
|
let readInput = false;
|
|
261
379
|
for (;;) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
380
|
+
// Styled capture here too: a ghost suggestion CONTAINING the payload
|
|
381
|
+
// (e.g. the TUI suggesting "/compact" right after it ran) would otherwise
|
|
382
|
+
// read as "still in the input" — a false non-submit whose retry path
|
|
383
|
+
// sends extra Enters into a live pane on the strength of chrome.
|
|
384
|
+
const pane = captureStyled(run, target);
|
|
385
|
+
if (pane !== null) {
|
|
386
|
+
const verdict = stillInInput(pane, payload);
|
|
265
387
|
if (verdict === false) return false;
|
|
266
388
|
if (verdict === true) readInput = true; // we could read the input line
|
|
267
389
|
}
|
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -461,6 +461,12 @@ function writeReceipts(msgs, outcome) {
|
|
|
461
461
|
ts: Date.now(),
|
|
462
462
|
from: m.from,
|
|
463
463
|
control: m.control === true,
|
|
464
|
+
// Build identity of the pusher that did the typing/verifying — the
|
|
465
|
+
// same module-graph stamp the transport marker carries (SCRIPT_MTIME
|
|
466
|
+
// covers hooks/*.mjs, not just this file). Lets deliveryOutcome note
|
|
467
|
+
// a "confirmed" issued by pre-upgrade verification logic. Omitted
|
|
468
|
+
// when unknown: absence reads as UNKNOWN downstream, never as fresh.
|
|
469
|
+
...(SCRIPT_MTIME !== undefined ? { scriptMtime: SCRIPT_MTIME } : {}),
|
|
464
470
|
...(outcome
|
|
465
471
|
? {
|
|
466
472
|
submitted: outcome.submitted === true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Guards against
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
2
|
+
// Guards against this package's own name appearing in its own dependency
|
|
3
|
+
// graph. A self-dependency makes `npm install` resolve against an old
|
|
4
|
+
// published copy instead of the local source, silently breaking dev installs.
|
|
5
|
+
//
|
|
6
|
+
// It has happened ONCE, not repeatedly — verified 2026-07-29 by `git log -S`
|
|
7
|
+
// over the whole history under both the current name and the pre-rename
|
|
8
|
+
// `claude-coord-mcp`: commit de4e1ee (2026-06-06) added
|
|
9
|
+
// "agent-coord-mcp": "^0.8.0" to dependencies, and 3bce3ce removed it six
|
|
10
|
+
// days later. An earlier version of this comment claimed two reintroductions,
|
|
11
|
+
// one of them manual; that second one is not in the history.
|
|
12
|
+
//
|
|
13
|
+
// What made the one occurrence dangerous is worth keeping, because it
|
|
14
|
+
// generalises: de4e1ee is titled `chore: npm audit fix` and its body says
|
|
15
|
+
// "Lockfile-only dependency bumps, non-breaking" — while also rewriting a
|
|
16
|
+
// dependency block. It passed review BECAUSE the message said the manifest
|
|
17
|
+
// was untouched; a reviewer reads the claim, not the diff. This guard is
|
|
18
|
+
// cheap insurance against that class, not evidence of a recurring bug.
|
|
19
|
+
//
|
|
20
|
+
// Runs on `prepare` (every install), `pretest`, and `prepublishOnly`.
|
|
11
21
|
import { readFileSync } from "node:fs";
|
|
12
22
|
|
|
13
23
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -35,7 +45,9 @@ if (offenders.length || lockOffender) {
|
|
|
35
45
|
`[check-self-dependency] "${pkg.name}" depends on itself` +
|
|
36
46
|
(offenders.length ? ` in package.json's ${offenders.join(", ")}` : "") +
|
|
37
47
|
(lockOffender ? `${offenders.length ? " and" : " in"} package-lock.json's root package` : "") +
|
|
38
|
-
`.\nThis
|
|
48
|
+
`.\nThis happened once before, added by an "npm audit fix" whose commit message said it was ` +
|
|
49
|
+
`lockfile-only (de4e1ee, removed in 3bce3ce — see docs/DONE.md). If you just ran an audit or ` +
|
|
50
|
+
`dependency update, check what else it changed in package.json. ` +
|
|
39
51
|
`Remove the self-reference from both files and re-run "npm install".`,
|
|
40
52
|
);
|
|
41
53
|
process.exit(1);
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
24
|
|
|
25
|
-
const EXPECTED_TESTS =
|
|
25
|
+
const EXPECTED_TESTS = 262;
|
|
26
26
|
|
|
27
27
|
const expected = Number(process.env.AGENT_COORD_EXPECTED_TESTS ?? EXPECTED_TESTS);
|
|
28
28
|
// Same glob the suite always used — `--test test/` would recurse differently
|
package/scripts/coord-pusher.mjs
CHANGED
|
@@ -114,15 +114,41 @@ try {
|
|
|
114
114
|
} catch (e) {
|
|
115
115
|
die(`register failed: ${e?.message ?? e}`);
|
|
116
116
|
}
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
// Build identity of THIS pusher process: newest mtime across the entry file
|
|
118
|
+
// AND its ../hooks/*.mjs imports, sampled once at startup. The entry file
|
|
119
|
+
// alone is not enough — the paste/submit pipeline lives in hooks/submit.mjs,
|
|
120
|
+
// so a fix landing there alone would leave a single-file stamp unchanged and
|
|
121
|
+
// a still-running pusher on the old code would read as fresh (#28's lesson,
|
|
122
|
+
// carried across the wire). Over-covering hooks siblings we don't import is
|
|
123
|
+
// the safe direction: a spurious stale flag is loud and cheap, a false fresh
|
|
124
|
+
// silently invalidates rollout verification. Stamped on the transport marker
|
|
125
|
+
// AND on every receipt this process reports; undefined on failure — an
|
|
126
|
+
// absent stamp reads as UNKNOWN downstream, never as fresh, and a partial
|
|
127
|
+
// (entry-only) value could read fresh while submit.mjs is exactly the stale
|
|
128
|
+
// part.
|
|
129
|
+
const scriptMtime = await (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const { statSync, readdirSync } = await import("node:fs");
|
|
132
|
+
const { fileURLToPath } = await import("node:url");
|
|
133
|
+
const path = await import("node:path");
|
|
134
|
+
const self = fileURLToPath(import.meta.url);
|
|
135
|
+
let newest = statSync(self).mtimeMs;
|
|
136
|
+
const hooksDir = path.join(path.dirname(self), "..", "hooks");
|
|
137
|
+
for (const f of readdirSync(hooksDir)) {
|
|
138
|
+
if (!f.endsWith(".mjs")) continue;
|
|
139
|
+
let m;
|
|
140
|
+
try {
|
|
141
|
+
m = statSync(path.join(hooksDir, f)).mtimeMs;
|
|
142
|
+
} catch {
|
|
143
|
+
continue; // deleted mid-scan
|
|
144
|
+
}
|
|
145
|
+
if (m > newest) newest = m;
|
|
146
|
+
}
|
|
147
|
+
return newest;
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined; // non-fatal — absence stays honest
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
126
152
|
try {
|
|
127
153
|
await call(
|
|
128
154
|
"report_transport",
|
|
@@ -323,6 +349,10 @@ async function reportReceipts(msgs, outcome) {
|
|
|
323
349
|
id: m.id,
|
|
324
350
|
...(m.from !== undefined ? { from: m.from } : {}),
|
|
325
351
|
control: m.control === true,
|
|
352
|
+
// Our build identity (module-graph mtime, see the startup stamp) —
|
|
353
|
+
// ties this receipt to the code that typed/verified the delivery.
|
|
354
|
+
// Omitted when unknown; the server must not default it.
|
|
355
|
+
...(scriptMtime !== undefined ? { scriptMtime } : {}),
|
|
326
356
|
...(outcome
|
|
327
357
|
? {
|
|
328
358
|
submitted: outcome.submitted === true,
|
package/src/server.ts
CHANGED
|
@@ -4,8 +4,16 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
-
import {
|
|
7
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
import {
|
|
9
|
+
ensureDirs,
|
|
10
|
+
getTokenMap,
|
|
11
|
+
reloadTokenMapSync,
|
|
12
|
+
sessionFile,
|
|
13
|
+
type SessionBinding,
|
|
14
|
+
} from "./store.js";
|
|
15
|
+
import {
|
|
16
|
+
liveClaimEvidence,
|
|
9
17
|
attachAgentSchema,
|
|
10
18
|
attachAgentTool,
|
|
11
19
|
clearTransportSchema,
|
|
@@ -95,8 +103,103 @@ function jsonResult(data: unknown) {
|
|
|
95
103
|
// switching (the PR #45 spoof shape) is rejected.
|
|
96
104
|
// - rename_agent updates the binding to the new id on success so the
|
|
97
105
|
// renamed session keeps working.
|
|
98
|
-
|
|
106
|
+
// - First-claim guard (v0.20.0): TOFU no longer lets a fresh session claim
|
|
107
|
+
// an id that is currently LIVE on the bus (fresh heartbeat, live
|
|
108
|
+
// transport, or another live bound session) — that silently created a
|
|
109
|
+
// second session acting as an already-running agent (hit live 2026-07-06:
|
|
110
|
+
// a dev session bound itself to `disavow-liaison`). A live-id claim needs
|
|
111
|
+
// the agent's token or an explicit force (join/register params). See
|
|
112
|
+
// guardFirstClaim for how absent vs unreadable evidence is decided.
|
|
113
|
+
// - `trackSession` (stdio only): each successful bind writes a
|
|
114
|
+
// sessions/<id>.<pid>.<nonce>.json marker so doctor can SEE two live
|
|
115
|
+
// sessions bound to one id — closure state alone cannot be inspected
|
|
116
|
+
// from outside the process. Not tracked for HTTP sessions: tokens.json
|
|
117
|
+
// already enforces their identity and many share one pid, which would
|
|
118
|
+
// make pid-liveness meaningless.
|
|
119
|
+
function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {}): McpServer {
|
|
99
120
|
let bound = initialBound;
|
|
121
|
+
const trackSession = opts.trackSession ?? false;
|
|
122
|
+
let sessionMarker: string | undefined;
|
|
123
|
+
let exitHooksInstalled = false;
|
|
124
|
+
|
|
125
|
+
// Best-effort: a marker left behind by SIGKILL has a dead pid, which both
|
|
126
|
+
// the guard's evidence read and doctor's duplicate-session-binding check
|
|
127
|
+
// treat as garbage (doctor fix deletes it).
|
|
128
|
+
function recordSessionBinding(agentId: string, via: string): void {
|
|
129
|
+
if (!trackSession) return;
|
|
130
|
+
try {
|
|
131
|
+
const file = sessionFile(agentId, process.pid, randomUUID().slice(0, 8));
|
|
132
|
+
const marker: SessionBinding = {
|
|
133
|
+
agentId,
|
|
134
|
+
pid: process.pid,
|
|
135
|
+
boundAt: Date.now(),
|
|
136
|
+
via,
|
|
137
|
+
...(process.env.TMUX_PANE ? { tmuxPane: process.env.TMUX_PANE } : {}),
|
|
138
|
+
};
|
|
139
|
+
writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
|
|
140
|
+
if (sessionMarker) {
|
|
141
|
+
try { unlinkSync(sessionMarker); } catch { /* already gone */ }
|
|
142
|
+
}
|
|
143
|
+
sessionMarker = file;
|
|
144
|
+
if (!exitHooksInstalled) {
|
|
145
|
+
exitHooksInstalled = true;
|
|
146
|
+
const cleanup = () => {
|
|
147
|
+
try { if (sessionMarker) unlinkSync(sessionMarker); } catch { /* already gone */ }
|
|
148
|
+
};
|
|
149
|
+
process.on("exit", cleanup);
|
|
150
|
+
// Default signal death skips 'exit' handlers; SIGHUP stays reserved
|
|
151
|
+
// for the token-map reload in loadTokenMap.
|
|
152
|
+
for (const sig of ["SIGTERM", "SIGINT"] as const) {
|
|
153
|
+
process.on(sig, () => { cleanup(); process.exit(0); });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch { /* marker is observability, never worth failing the bind */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Decide whether a fresh session may claim `claimed` as its identity, and
|
|
160
|
+
// how. Returns the bind provenance ("tofu" | "token" | "force" |
|
|
161
|
+
// "same-pane") or throws. Ordering is deliberate:
|
|
162
|
+
// - a presented token must MATCH or the claim fails loudly, even when the
|
|
163
|
+
// id is not live — a wrong credential silently succeeding via the
|
|
164
|
+
// not-live path would teach callers that garbage tokens work;
|
|
165
|
+
// - force is an explicit human/agent decision, honored before evidence;
|
|
166
|
+
// - evidence that exists but cannot be read REFUSES (cannot-verify ≠
|
|
167
|
+
// verified-absent; unreadable state must not disable the guard);
|
|
168
|
+
// - a live id refuses, except when its live pusher types into THIS
|
|
169
|
+
// process's own tmux pane — two sessions cannot share a pane, so that
|
|
170
|
+
// is the same seat restarting (the routine fleet-restart case), not a
|
|
171
|
+
// second session. The exception never applies when another live
|
|
172
|
+
// session is already bound to the id.
|
|
173
|
+
// - verified-not-live binds freely: refusing absent evidence would break
|
|
174
|
+
// every first onboarding, and the guard exists to protect LIVE ids.
|
|
175
|
+
async function guardFirstClaim(claimed: string, args: Record<string, unknown>): Promise<string> {
|
|
176
|
+
const token = typeof args["token"] === "string" ? (args["token"] as string) : undefined;
|
|
177
|
+
if (token !== undefined) {
|
|
178
|
+
if (getTokenMap()?.get(token) === claimed) return "token";
|
|
179
|
+
throw new Error(
|
|
180
|
+
`token presented for '${claimed}' does not match tokens.json (or no token map is loaded). ` +
|
|
181
|
+
`Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (args["force"] === true) return "force";
|
|
185
|
+
const ev = await liveClaimEvidence(claimed, Date.now());
|
|
186
|
+
if (!ev.verifiable) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`cannot verify whether '${claimed}' is live: ${ev.reasons.join("; ")}. ` +
|
|
189
|
+
`Refusing to bind rather than treating unreadable evidence as absence. ` +
|
|
190
|
+
`Repair the state (doctor), or pass the agent's token or force:true (join/register).`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (ev.live) {
|
|
194
|
+
if (ev.samePane && ev.boundElsewhere === 0) return "same-pane";
|
|
195
|
+
throw new Error(
|
|
196
|
+
`agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it. ` +
|
|
197
|
+
`If you ARE '${claimed}' restarting, re-join from its tmux pane, or pass its token or force:true (join/register). ` +
|
|
198
|
+
`If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return "tofu";
|
|
202
|
+
}
|
|
100
203
|
|
|
101
204
|
// Gate every tool that takes a caller identity. `field: null` (list_agents,
|
|
102
205
|
// list_rooms, prune) bypasses the check entirely.
|
|
@@ -117,7 +220,12 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
117
220
|
if (typeof claimed === "string") {
|
|
118
221
|
if (bound === undefined) {
|
|
119
222
|
if (bindOnClaim) {
|
|
120
|
-
|
|
223
|
+
// TOFU: first claim wins, then sticky — but only after the
|
|
224
|
+
// first-claim guard agrees the id isn't someone else's live
|
|
225
|
+
// session (see guardFirstClaim).
|
|
226
|
+
const via = await guardFirstClaim(claimed, args);
|
|
227
|
+
bound = claimed;
|
|
228
|
+
recordSessionBinding(claimed, via);
|
|
121
229
|
}
|
|
122
230
|
} else if (bound !== claimed) {
|
|
123
231
|
throw new Error(
|
|
@@ -137,7 +245,7 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
137
245
|
|
|
138
246
|
server.tool(
|
|
139
247
|
"join",
|
|
140
|
-
"Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated.",
|
|
248
|
+
"Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for.",
|
|
141
249
|
joinSchema,
|
|
142
250
|
// join explicitly sets the session binding when unset, so each agent can
|
|
143
251
|
// declare its identity via join rather than relying on env vars.
|
|
@@ -145,7 +253,9 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
145
253
|
const claimed = args["agentId"];
|
|
146
254
|
if (typeof claimed === "string") {
|
|
147
255
|
if (bound === undefined) {
|
|
256
|
+
const via = await guardFirstClaim(claimed, args);
|
|
148
257
|
bound = claimed;
|
|
258
|
+
recordSessionBinding(claimed, via);
|
|
149
259
|
} else if (bound !== claimed) {
|
|
150
260
|
throw new Error(
|
|
151
261
|
`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
|
|
@@ -249,7 +359,7 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
249
359
|
|
|
250
360
|
server.tool(
|
|
251
361
|
"prune",
|
|
252
|
-
"Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7); kind='decision' posts keep a longer `decisionDays` retention (default 30). Nothing is lost: aged-out entries are archived under archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl) — only receipts are truly deleted.
|
|
362
|
+
"Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7); kind='decision' posts keep a longer `decisionDays` retention (default 30). Nothing is lost: aged-out entries are archived under archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl) — only receipts are truly deleted. `room` and `targets` compose: `room` scopes every sweep to that channel (and, alone, defaults the sweep to `rooms` only), while `targets` (rooms|status|inbox|receipts|members) selects which sweeps run and always wins over that default — so `{room, targets:['members']}` sweeps membership in that one channel. Sweeps room members that are unregistered or haven't heartbeated since the cutoff, and archives+removes non-default rooms left empty and inactive (disable via archiveEmptyRooms=false). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
|
|
253
363
|
pruneSchema,
|
|
254
364
|
gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
255
365
|
);
|
|
@@ -306,15 +416,23 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
306
416
|
async (args: Record<string, unknown>) => {
|
|
307
417
|
const claimed = args.agentId;
|
|
308
418
|
if (typeof claimed === "string") {
|
|
309
|
-
if (bound === undefined)
|
|
310
|
-
|
|
419
|
+
if (bound === undefined) {
|
|
420
|
+
// Renaming a live agent from a fresh session is still a first
|
|
421
|
+
// claim of that agent's identity — same guard as any other.
|
|
422
|
+
const via = await guardFirstClaim(claimed, args);
|
|
423
|
+
bound = claimed;
|
|
424
|
+
recordSessionBinding(claimed, via);
|
|
425
|
+
} else if (bound !== claimed) {
|
|
311
426
|
throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
|
|
312
427
|
}
|
|
313
428
|
}
|
|
314
429
|
const result = await renameAgentTool(args as { agentId: string; newAgentId: string });
|
|
315
430
|
if (result && typeof result === "object" && (result as { ok?: unknown }).ok === true) {
|
|
316
431
|
const to = (result as { to?: unknown }).to;
|
|
317
|
-
if (typeof to === "string")
|
|
432
|
+
if (typeof to === "string") {
|
|
433
|
+
bound = to;
|
|
434
|
+
recordSessionBinding(to, "rename");
|
|
435
|
+
}
|
|
318
436
|
}
|
|
319
437
|
return jsonResult(result);
|
|
320
438
|
},
|
|
@@ -404,6 +522,10 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
404
522
|
gate(null, forceUnregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
405
523
|
);
|
|
406
524
|
|
|
525
|
+
// An env pre-bound session is just as live as a TOFU-bound one — record it
|
|
526
|
+
// so doctor's duplicate check sees it too. (No-op unless trackSession.)
|
|
527
|
+
if (initialBound) recordSessionBinding(initialBound, "env");
|
|
528
|
+
|
|
407
529
|
return server;
|
|
408
530
|
}
|
|
409
531
|
|
|
@@ -452,7 +574,7 @@ async function main() {
|
|
|
452
574
|
" unregister before restarting so the new name starts fresh.",
|
|
453
575
|
);
|
|
454
576
|
}
|
|
455
|
-
const server = buildServer(boundAgent);
|
|
577
|
+
const server = buildServer(boundAgent, { trackSession: true });
|
|
456
578
|
const transport = new StdioServerTransport();
|
|
457
579
|
await server.connect(transport);
|
|
458
580
|
}
|
package/src/store.ts
CHANGED
|
@@ -55,6 +55,37 @@ export const ARCHIVE_ROOMS_DIR = path.join(ARCHIVE_DIR, "rooms");
|
|
|
55
55
|
export const ARCHIVE_INBOX_DIR = path.join(ARCHIVE_DIR, "inbox");
|
|
56
56
|
export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl");
|
|
57
57
|
|
|
58
|
+
// Live session-binding markers (v0.20.0). One small file per *bound* stdio MCP
|
|
59
|
+
// session: which agentId the session claimed, which pid holds it, and how the
|
|
60
|
+
// bind was established. Written at bind time, removed on clean exit; a file
|
|
61
|
+
// whose pid is dead is garbage doctor can clean. This is what makes two live
|
|
62
|
+
// sessions bound to the same id VISIBLE (doctor `duplicate-session-binding`)
|
|
63
|
+
// — in-process closure state can't be, by definition. HTTP sessions are not
|
|
64
|
+
// tracked here: with tokens.json they are already identity-enforced, and many
|
|
65
|
+
// share one pid, so pid-liveness would be meaningless for them.
|
|
66
|
+
export const SESSIONS_DIR = path.join(ROOT, "sessions");
|
|
67
|
+
|
|
68
|
+
export type SessionBinding = {
|
|
69
|
+
agentId: string;
|
|
70
|
+
pid: number;
|
|
71
|
+
boundAt: number;
|
|
72
|
+
// How the bind was established: "tofu" (first claim, id verified not live),
|
|
73
|
+
// "env" (AGENT_COORD_BOUND_AGENT), "token", "force", "same-pane" (live
|
|
74
|
+
// marker types into this session's own tmux pane), "rename".
|
|
75
|
+
via: string;
|
|
76
|
+
tmuxPane?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function sessionFile(agentId: string, pid: number, nonce: string): string {
|
|
80
|
+
return path.join(SESSIONS_DIR, `${sanitize(agentId)}.${pid}.${nonce}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function listSessionFiles(): Promise<string[]> {
|
|
84
|
+
if (!existsSync(SESSIONS_DIR)) return [];
|
|
85
|
+
const names = await fs.readdir(SESSIONS_DIR);
|
|
86
|
+
return names.filter((n) => n.endsWith(".json")).map((n) => path.join(SESSIONS_DIR, n));
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
// Per-agent token map for identity-bound bus auth (v0.7.0). Shape on disk:
|
|
59
90
|
// { "alice": "tk_<random-secret>", "bob": "tk_<another-secret>" }
|
|
60
91
|
// HTTP transport reverse-looks-up the bearer to bind the session to an
|
|
@@ -82,7 +113,7 @@ export function workFile(project: string): string {
|
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
export function ensureDirs(): void {
|
|
85
|
-
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR]) {
|
|
116
|
+
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR, SESSIONS_DIR]) {
|
|
86
117
|
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
87
118
|
}
|
|
88
119
|
for (const f of [ROOM_FILE, STATUS_FILE]) {
|
|
@@ -330,6 +361,19 @@ export async function readJson<T>(file: string, fallback: T): Promise<T> {
|
|
|
330
361
|
}
|
|
331
362
|
}
|
|
332
363
|
|
|
364
|
+
// Like readJson, but a file that EXISTS and cannot be parsed THROWS instead of
|
|
365
|
+
// silently returning the fallback. For callers whose decision flips on
|
|
366
|
+
// "verified absent" vs "cannot verify": the first-claim binding guard must
|
|
367
|
+
// refuse when evidence is unreadable, because a guard that treats unreadable
|
|
368
|
+
// evidence as absent is disabled by the very corruption it should be
|
|
369
|
+
// reporting (the absence-is-not-exemption class, #36).
|
|
370
|
+
export async function readJsonStrict<T>(file: string, fallback: T): Promise<T> {
|
|
371
|
+
if (!existsSync(file)) return fallback;
|
|
372
|
+
const raw = await fs.readFile(file, "utf8");
|
|
373
|
+
if (!raw.trim()) return fallback;
|
|
374
|
+
return JSON.parse(raw) as T;
|
|
375
|
+
}
|
|
376
|
+
|
|
333
377
|
export async function writeJson(file: string, data: unknown): Promise<void> {
|
|
334
378
|
await withLock(file, async () => {
|
|
335
379
|
await fs.writeFile(file, JSON.stringify(data, null, 2), "utf8");
|