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/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
- // `null` for either field means UNKNOWN — the pane could not be captured, or
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) return { busy: null, draft: null };
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
- const busy = busyRe ? new RegExp(busyRe).test(text) : null;
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").map((l) => l.replace(/\s+$/, ""));
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
- const m = re.exec(lines[i]);
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
- const cap = run(["capture-pane", "-p", "-t", target]);
263
- if (cap.status === 0) {
264
- const verdict = stillInInput(String(cap.stdout ?? ""), payload);
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
  }
@@ -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.18.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 a recurring defect (see docs/QUEUE.md P1): this package's
3
- // own name has twice been reintroduced into its own dependency graph —
4
- // once manually, once by an `npm audit fix` run (commit de4e1ee added
5
- // "agent-coord-mcp": "^0.8.0" back into dependencies while patching
6
- // fast-uri/hono/ip-address/qs). A self-dependency makes `npm install`
7
- // resolve against an old published copy of this package instead of the
8
- // local source, silently breaking dev installs. Run on every `npm install`
9
- // (via the "prepare" script) so a reintroduction fails loudly and
10
- // immediately instead of sitting unnoticed until the next audit.
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 has recurred before — a prior "npm audit fix" run reintroduced it (see docs/QUEUE.md P1). ` +
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 = 218;
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
@@ -114,15 +114,41 @@ try {
114
114
  } catch (e) {
115
115
  die(`register failed: ${e?.message ?? e}`);
116
116
  }
117
- // Stamp the mtime of THIS process's loaded script so doctor() can spot a
118
- // stale remote pusher after a remote-side upgrade (see v0.8.2 stale-pusher
119
- // check — same hazard as the local tmux-pusher).
120
- let scriptMtime;
121
- try {
122
- const { statSync } = await import("node:fs");
123
- const { fileURLToPath } = await import("node:url");
124
- scriptMtime = statSync(fileURLToPath(import.meta.url)).mtimeMs;
125
- } catch { /* non-fatal */ }
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 { ensureDirs, getTokenMap, reloadTokenMapSync } from "./store.js";
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
- function buildServer(initialBound?: string): McpServer {
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
- bound = claimed; // TOFU: first claim wins, then sticky.
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. Pass `room` to prune a single channel, or `targets` (rooms|status|inbox|receipts|members) to narrow the sweep. 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.",
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) bound = claimed;
310
- else if (bound !== claimed) {
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") bound = to;
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");