agent-coord-mcp 0.26.25 → 0.26.27

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.
@@ -26,13 +26,28 @@
26
26
  * instead of reading the field, and a `string` discriminant cannot tell the
27
27
  * compiler that a branch was missed. Not a two-member union either — see (1).
28
28
  */
29
+ /**
30
+ * ⟨q-ec020f6a⟩ LOCAL tmux-push was DELETED outright (0 of 13 live seats used it; keeping
31
+ * it meant a second delivery mechanism — hooks/tmux-pusher.mjs, now removed — plus a
32
+ * role-card precondition that halted every live herdr seat on a false requirement).
33
+ *
34
+ * `TMUX_PUSH` stays DEFINED AND EXPORTED, but OUT of `TransportKind`/`TRANSPORT_KINDS`, for
35
+ * exactly one reason: a marker written to disk before this change can still literally read
36
+ * `"tmux-push"`, and `isLocallyProbeable`/`isTmuxKind` below must keep classifying that
37
+ * HISTORICAL value correctly (never `"dead"` where it used to read `"unknown"`) even
38
+ * though nothing can construct a NEW one — `resolveTransport` has no case for it and
39
+ * `config.ts` refuses it. Classifying old data and constructing new data are different
40
+ * questions; removing it from the union answers only the second.
41
+ */
29
42
  export const TMUX_PUSH = "tmux-push";
30
43
  export const TMUX_PUSH_REMOTE = "tmux-push-remote";
31
44
  export const HERDR = "herdr";
32
- export const TRANSPORT_KINDS = [TMUX_PUSH, TMUX_PUSH_REMOTE, HERDR];
45
+ export const TRANSPORT_KINDS = [TMUX_PUSH_REMOTE, HERDR];
33
46
  /**
34
- * The tmux family. Both members are delivered by a pusher process typing into a
35
- * pane; they differ in WHERE that pane is, which is why liveness splits below.
47
+ * The tmux family, for LIVENESS CLASSIFICATION of whatever a marker on disk says —
48
+ * including the historical, no-longer-constructible local kind (`TMUX_PUSH`, above).
49
+ * Both members are delivered by a pusher process typing into a pane; they differed in
50
+ * WHERE that pane was, which is why liveness splits below.
36
51
  *
37
52
  * THIS IS THE ONE PLACE THE TMUX LITERALS LIVE. Twelve call sites used to spell
38
53
  * them; they now ask.
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transports/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAoB,CAAC;AAC9C,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAA2B,CAAC;AAC5D,MAAM,CAAC,MAAM,KAAK,GAAG,OAAgB,CAAC;AAItC,MAAM,CAAC,MAAM,eAAe,GAA6B,CAAC,SAAS,EAAE,gBAAgB,EAAE,KAAK,CAAU,CAAC;AAEvG;;;;;;GAMG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAElE,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,SAA6B;IACtD,OAAO,SAAS,KAAK,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAA6B;IAC9D,OAAO,SAAS,KAAK,SAAS,CAAC;AACjC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,SAA6B;IAC5D,OAAO,SAAS,KAAK,gBAAgB,CAAC;AACxC,CAAC;AAyDD;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAsD;IAC7E,OAAO,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAmB,MAAS,EAAE,MAA0B;IAChF,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;AACnD,CAAC;AAeD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,CAAU,CAAC;AAGnE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,cAAc,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAC3I,MAAM,CAAC,MAAM,aAAa,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAKxJ,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,SAAS,EAAE,eAAe,CAAU,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transports/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;GAMG;AACH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAoB,CAAC;AAC9C,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAA2B,CAAC;AAC5D,MAAM,CAAC,MAAM,KAAK,GAAG,OAAgB,CAAC;AAItC,MAAM,CAAC,MAAM,eAAe,GAA6B,CAAC,gBAAgB,EAAE,KAAK,CAAU,CAAC;AAE5F;;;;;;;;GAQG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAElE,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,SAA6B;IACtD,OAAO,SAAS,KAAK,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAA6B;IAC9D,OAAO,SAAS,KAAK,SAAS,CAAC;AACjC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,SAA6B;IAC5D,OAAO,SAAS,KAAK,gBAAgB,CAAC;AACxC,CAAC;AAyDD;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAsD;IAC7E,OAAO,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAmB,MAAS,EAAE,MAA0B;IAChF,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;AACnD,CAAC;AAeD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,CAAU,CAAC;AAGnE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,cAAc,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAC3I,MAAM,CAAC,MAAM,aAAa,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAKxJ,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,SAAS,EAAE,eAAe,CAAU,CAAC"}
package/hooks/submit.mjs CHANGED
@@ -323,7 +323,18 @@ export function readyProfileStartupLine(who) {
323
323
  // How many lines may sit below the input box's lower rule. Claude Code draws a footer there
324
324
  // (mode line, hints); a dialog, picker or autocomplete menu draws many more.
325
325
  export const READY_BOX_MAX_BELOW = 4;
326
- const RULE_RE = /^─{8,}$/;
326
+ // ⟨q-c8032c5f⟩ herdr's `pane read --source visible` can draw a SHORT SINGLE-TOKEN pane label
327
+ // into the middle of an otherwise-unbroken rule (measured on a live seat: 201 U+2500 dashes
328
+ // with " qa2 " spliced in near the end) — herdr's own frame chrome overlaid onto the exact
329
+ // row Claude Code drew as a plain rule, not something Claude Code's own render ever does.
330
+ // A pure exact-match (`/^─{8,}$/`) read that as "not a rule at all" and held every push to
331
+ // that seat: the line WAS the rule, annotated, and the annotation is what broke the match —
332
+ // not the pane, not the transport, not the seat. Tolerate exactly one such label (a single
333
+ // run of non-space, non-dash characters, flanked by spaces, flanked by dashes on both sides)
334
+ // and nothing looser: multi-word content, a label with no dashes on one side, or two labels
335
+ // still fail — those are genuinely not a rule, and loosening further would just move the
336
+ // false-refusal into a false-accept instead of fixing it.
337
+ const RULE_RE = /^─{8,}(?: [^\s─]+ )?─*$/;
327
338
  const BOX_LINE_RE = /^❯(?:\s|$)/;
328
339
  const MAX_DRAFT_LINES = 20;
329
340
 
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.25",
3
+ "version": "0.26.27",
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": {
7
7
  "agent-coord-mcp": "dist/server.js",
8
8
  "coord-chat": "scripts/coord-chat.mjs",
9
- "coord-pusher": "scripts/coord-pusher.mjs"
9
+ "coord-pusher": "scripts/coord-pusher.mjs",
10
+ "coord-token": "scripts/coord-token.mjs",
11
+ "coord-seat": "scripts/coord-seat.mjs"
10
12
  },
11
13
  "scripts": {
12
14
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // ⟨q-8c23e5a3⟩ acceptance (c) — a GLOBAL `agent-coord` MCP entry must never be a working
3
+ // fallback. Claude Code resolves `.mcp.json` at project scope first, but falls back to a
4
+ // user/global scope config when a project has none of its own — so a seat launched from a
5
+ // project that forgot `--emit-mcp-config` does not fail loudly, it comes up healthy,
6
+ // wearing whatever identity the global entry's token resolves to. That is the SAME class as
7
+ // ⟨q-e439e4ad⟩ (an omitted, seat-specific fact silently answered by someone else's context)
8
+ // one layer up, at config-resolution time instead of attach time.
9
+ //
10
+ // This is a DOCTOR, not a gate link: it inspects files outside this repo (the operator's
11
+ // global Claude config), so its population is one machine's state, not this tree's — the
12
+ // same distinction check-doc-links.mjs draws for its external file:// links. Run it by hand
13
+ // after any global MCP config change: `node packages/coord-mcp/scripts/check-global-mcp-fallback.mjs`.
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import path from "node:path";
17
+
18
+ /** Every location Claude Code can resolve a GLOBAL (non-project) `mcpServers` map from. */
19
+ export function globalMcpConfigPaths(home = homedir()) {
20
+ return [path.join(home, ".claude", ".mcp.json"), path.join(home, ".claude", ".claude.json")];
21
+ }
22
+
23
+ /** Read one config file's `mcpServers` map, or `null` if absent/unreadable-as-JSON. */
24
+ function mcpServersOf(file) {
25
+ if (!existsSync(file)) return null;
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(readFileSync(file, "utf8"));
29
+ } catch {
30
+ return null; // unreadable is not this check's population — a parse-error gate exists elsewhere if needed
31
+ }
32
+ const servers = parsed?.mcpServers;
33
+ return servers && typeof servers === "object" && !Array.isArray(servers) ? servers : null;
34
+ }
35
+
36
+ /**
37
+ * Is a global `agent-coord` entry a WORKING fallback? "Working" means a seat that
38
+ * inherits it could actually authenticate as someone: an http/https url, plus either a
39
+ * non-empty bearer header or (advisory mode) no header at all — a url with an EMPTY or
40
+ * absent Authorization header, or no url, is not something a stray seat could complete a
41
+ * handshake through.
42
+ */
43
+ export function isWorkingFallback(entry) {
44
+ if (!entry || typeof entry !== "object") return false;
45
+ const url = entry.url;
46
+ if (typeof url !== "string" || !/^https?:\/\//.test(url)) return false;
47
+ const auth = entry.headers?.Authorization;
48
+ if (typeof auth === "string" && /^Bearer\s+\S+/.test(auth)) return true;
49
+ // No header at all is advisory-mode shaped — still a working fallback IF a shared
50
+ // bearer is expected server-side, which this file cannot see. Treat "has a real url,
51
+ // no header" as working too: the absence of evidence it's inert is not evidence it is.
52
+ return auth === undefined;
53
+ }
54
+
55
+ /** Every global config path carrying a live `agent-coord` entry, with the reason. */
56
+ export function findLiveGlobalFallbacks(home = homedir()) {
57
+ const hits = [];
58
+ for (const file of globalMcpConfigPaths(home)) {
59
+ const servers = mcpServersOf(file);
60
+ const entry = servers?.["agent-coord"];
61
+ if (entry && isWorkingFallback(entry)) hits.push({ file, entry });
62
+ }
63
+ return hits;
64
+ }
65
+
66
+ function main() {
67
+ const hits = findLiveGlobalFallbacks();
68
+ if (hits.length === 0) {
69
+ console.log("OK: no global `agent-coord` MCP entry is a working fallback on this machine.");
70
+ return;
71
+ }
72
+ console.error(
73
+ `⛔ ${hits.length} global MCP config(s) carry a WORKING \`agent-coord\` fallback — a seat ` +
74
+ `launched from a project with no per-seat .mcp.json (⟨q-8c23e5a3⟩'s --emit-mcp-config) ` +
75
+ `would inherit this identity silently instead of refusing at start:`,
76
+ );
77
+ for (const h of hits) console.error(` ${h.file} -> mcpServers.agent-coord: ${JSON.stringify(h.entry)}`);
78
+ console.error(
79
+ " Fix: remove the `agent-coord` key from each file above, or point its `url` at nothing " +
80
+ "reachable and its `headers.Authorization` at an empty/no token.",
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ if (import.meta.url === `file://${process.argv[1]}`) main();
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // coord-seat — ⟨q-8c23e5a3⟩ acceptance (d): the FIVE manual steps of putting one agent
3
+ // on the HTTP bus, composed into one command. Registering a seat used to mean: (1) mint a
4
+ // token, (2) hand-write a per-project .mcp.json, (3) remember --mcp-config or silently
5
+ // inherit whatever the global entry answers to, (4) find/create a pane and pass it to
6
+ // attach_agent by hand (⟨q-e439e4ad⟩, now refused rather than misattached), (5) know which
7
+ // role-card skill to hand the seat. Two of those five failed SILENTLY into the wrong
8
+ // identity or the wrong terminal. This is the composite default path; the five steps
9
+ // above remain available separately for anyone who wants to do one by hand.
10
+ //
11
+ // Usage:
12
+ // coord-seat <agent-id> --server <url> --dir <project-dir> --role <roleId> \
13
+ // [--pane <target>] [--project <name>] [--cmd claude] [--card <skill-name>]
14
+ //
15
+ // --pane is OPTIONAL here only: if omitted, coord-seat tries `herdr pane new` to create one
16
+ // and use its id. If herdr cannot create a pane (binary absent, or no such subcommand on
17
+ // this herdr version), it REFUSES rather than falling back to an omitted target — the one
18
+ // thing this whole queue item exists to stop.
19
+ //
20
+ // ⛔ ALL ARGV PARSING LIVES INSIDE main(), NOT AT MODULE SCOPE: this file is imported
21
+ // directly by its own tests to exercise resolvePane()/main() as functions, and top-level
22
+ // code that reads process.argv would run (and die() on a bare `node --test`'s own argv)
23
+ // the instant the import happened — the same class of defect this queue item is about,
24
+ // just at import time instead of attach time.
25
+ import { spawnSync } from "node:child_process";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
30
+ const COORD_TOKEN = path.join(HERE, "coord-token.mjs");
31
+
32
+ function die(msg) {
33
+ process.stderr.write(`coord-seat: ${msg}\n`);
34
+ process.exit(1);
35
+ }
36
+
37
+ /** Try to mint a fresh pane through herdr. `null` if herdr can't (absent, or refuses). */
38
+ function tryHerdrPaneNew(run = (args) => spawnSync("herdr", args, { encoding: "utf8" })) {
39
+ const r = run(["pane", "new"]);
40
+ if (r.error || r.status !== 0) return null;
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(r.stdout);
44
+ } catch {
45
+ return null;
46
+ }
47
+ return parsed?.result?.pane?.pane_id ?? parsed?.pane_id ?? null;
48
+ }
49
+
50
+ export function resolvePane(explicitPane, run) {
51
+ if (explicitPane) return explicitPane;
52
+ const minted = tryHerdrPaneNew(run);
53
+ if (minted) return minted;
54
+ die(
55
+ "no --pane given and `herdr pane new` could not mint one (binary absent, or this herdr " +
56
+ "version has no such subcommand) — refusing rather than emitting a config with an " +
57
+ "omitted pane. Create a pane yourself and pass --pane <target>.",
58
+ );
59
+ }
60
+
61
+ export function main(run = (args) => spawnSync("herdr", args, { encoding: "utf8" }), argv = process.argv.slice(2)) {
62
+ function opt(name, fallback) {
63
+ const i = argv.indexOf(name);
64
+ return i >= 0 ? argv[i + 1] : fallback;
65
+ }
66
+ const agentId = argv[0];
67
+ if (!agentId || agentId.startsWith("--")) die("usage: coord-seat <agent-id> --server <url> --dir <project-dir> --role <roleId> [--pane <target>]");
68
+ const server = opt("--server");
69
+ const dir = opt("--dir");
70
+ const role = opt("--role", "worker");
71
+ const project = opt("--project");
72
+ const cmd = opt("--cmd", "claude");
73
+ const card = opt("--card", `coord-${role}`);
74
+ if (!server) die("--server <url> is required");
75
+ if (!dir) die("--dir <project-dir> is required");
76
+
77
+ const pane = resolvePane(opt("--pane"), run);
78
+
79
+ const tokenArgs = ["add", agentId, "--emit-mcp-config", dir, "--server", server, "--pane", pane];
80
+ const minted = spawnSync(process.execPath, [COORD_TOKEN, ...tokenArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
81
+ if (minted.status !== 0) die(`coord-token add failed:\n${minted.stderr}`);
82
+ process.stderr.write(minted.stderr);
83
+
84
+ const roleFlags = [`--id=${agentId}`, `--role=${role}`].concat(project ? [`--project=${project}`] : []);
85
+ console.error(
86
+ `\ncoord-seat '${agentId}' ready:\n` +
87
+ ` 1. pane: ${pane}\n` +
88
+ ` 2. config: ${dir}/.mcp.json (mode 600)\n` +
89
+ ` 3. launch: cd ${dir} && ${cmd}\n` +
90
+ ` 4. attach: attach_agent { agentId: "${agentId}", tmuxTarget: "${pane}", includeRoom: false }\n` +
91
+ ` 5. hand off the role card: /${card} ${roleFlags.join(" ")}\n`,
92
+ );
93
+ return { pane, dir, agentId };
94
+ }
95
+
96
+ if (import.meta.url === `file://${process.argv[1]}`) main();
@@ -7,6 +7,13 @@
7
7
  //
8
8
  // Usage:
9
9
  // scripts/coord-token.mjs add <agent-id> mint (or rotate) a token, print it
10
+ // [--emit-mcp-config <dir>] ALSO write <dir>/.mcp.json (mode 600)
11
+ // --server <url> required with --emit-mcp-config: the
12
+ // HTTP bus URL this seat's config points at
13
+ // --pane <target> required with --emit-mcp-config: the
14
+ // herdr/tmux pane THIS seat will run in —
15
+ // see ⟨q-8c23e5a3⟩ below for why this can't
16
+ // be optional
10
17
  // scripts/coord-token.mjs list list agent ids (never prints tokens)
11
18
  // scripts/coord-token.mjs revoke <agent-id> remove a token
12
19
  // [--dir <coord-dir>] override AGENT_COORD_DIR
@@ -14,6 +21,15 @@
14
21
  // tokens.json is created 0600 (and re-chmod'd on every write) so secrets are
15
22
  // never group/other-readable. After add/revoke, SIGHUP the running bus to
16
23
  // reload its token map (kill -HUP <bus-pid>).
24
+ //
25
+ // ⟨q-8c23e5a3⟩ --emit-mcp-config REQUIRES --pane, not optionally accepts it: the whole
26
+ // point is that a seat onboarded this way never has an OMITTED target to fall back from —
27
+ // ⟨q-e439e4ad⟩ made the omitted-target case refuse at attach time, and this makes the
28
+ // omission impossible one step earlier, at mint time, for every seat that goes through
29
+ // this path. The printed instructions carry the exact `attach_agent` call with the pane
30
+ // already filled in, because there is no server-side env var that means "this seat's pane"
31
+ // under the shared HTTP daemon — one process, every seat, so a per-seat fact has to travel
32
+ // in what the OPERATOR (or the agent's own first tool call) is handed, not in `process.env`.
17
33
 
18
34
  import { randomBytes } from "node:crypto";
19
35
  import {
@@ -69,6 +85,44 @@ function save(map) {
69
85
  chmodSync(TOKENS_FILE, 0o600); // enforce even when the file pre-existed looser
70
86
  }
71
87
 
88
+ /**
89
+ * Write a project-local `.mcp.json` (⟨q-8c23e5a3⟩ acceptance (a)) whose `agent-coord`
90
+ * entry embeds this seat's own token in the `Authorization` header — never a shared
91
+ * default, so a project that gets its OWN config never depends on (or silently becomes)
92
+ * a global fallback. mode 600, same as tokens.json: a bearer token is a secret regardless
93
+ * of which file holds it. `pane` is recorded under `_agentCoordPane`, a key no MCP client
94
+ * reads or writes — it exists so a re-run of this script, or `coord-seat`, can recover the
95
+ * pane a config was minted for without re-asking the operator, and so a human reading the
96
+ * file sees the fact `attach_agent` will need, instead of it living only in scrollback.
97
+ */
98
+ export function writeMcpConfig({ dir, agentId, server, token, pane }) {
99
+ mkdirSync(dir, { recursive: true });
100
+ const configPath = path.join(dir, ".mcp.json");
101
+ let existing = {};
102
+ if (existsSync(configPath)) {
103
+ try {
104
+ existing = JSON.parse(readFileSync(configPath, "utf8"));
105
+ } catch (e) {
106
+ die(`${configPath} exists and is not valid JSON (${e.message}) — refusing to overwrite blindly`);
107
+ }
108
+ }
109
+ const config = {
110
+ ...existing,
111
+ mcpServers: {
112
+ ...(existing.mcpServers ?? {}),
113
+ "agent-coord": {
114
+ type: "http",
115
+ url: server,
116
+ headers: { Authorization: `Bearer ${token}` },
117
+ ...(pane ? { _agentCoordPane: pane } : {}),
118
+ },
119
+ },
120
+ };
121
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
122
+ chmodSync(configPath, 0o600);
123
+ return configPath;
124
+ }
125
+
72
126
  const positional = argv.filter((a) => !a.startsWith("--"));
73
127
  const cmd = positional[0];
74
128
  const id = positional[1];
@@ -78,6 +132,17 @@ switch (cmd) {
78
132
  if (!id || !AGENT_ID_RE.test(id)) {
79
133
  die("usage: coord-token add <agent-id> (id: [a-zA-Z0-9._-], <=64 chars)");
80
134
  }
135
+ const emitDir = opt("--emit-mcp-config");
136
+ const server = opt("--server");
137
+ const pane = opt("--pane");
138
+ if (emitDir && !server) die("--emit-mcp-config requires --server <url>");
139
+ if (emitDir && !pane) {
140
+ die(
141
+ "--emit-mcp-config requires --pane <target> — a config with no pane recorded is exactly " +
142
+ "the omitted-target case ⟨q-e439e4ad⟩ made attach_agent refuse; this refuses one step " +
143
+ "earlier instead of emitting a config that would hit that refusal on first use.",
144
+ );
145
+ }
81
146
  const map = load();
82
147
  const rotating = id in map;
83
148
  const token = "tk_" + randomBytes(24).toString("base64url");
@@ -89,10 +154,20 @@ switch (cmd) {
89
154
  process.stderr.write(
90
155
  `${rotating ? "rotated" : "minted"} token for '${id}' in ${TOKENS_FILE}\n`,
91
156
  );
92
- process.stderr.write(
93
- `SIGHUP the running bus to load it (kill -HUP <bus-pid>), then on the node:\n` +
94
- ` AGENT_COORD_TOKEN=${token} scripts/coord-node.sh --server <url> --id ${id} --cmd claude\n`,
95
- );
157
+ if (emitDir) {
158
+ const configPath = writeMcpConfig({ dir: emitDir, agentId: id, server, token, pane });
159
+ process.stderr.write(
160
+ `mcp config written: ${configPath} (mode 600)\n` +
161
+ `launch: cd ${emitDir} && claude\n` +
162
+ `first tool call — the pane is not optional, name it every time:\n` +
163
+ ` attach_agent { agentId: "${id}", tmuxTarget: "${pane}", includeRoom: false }\n`,
164
+ );
165
+ } else {
166
+ process.stderr.write(
167
+ `SIGHUP the running bus to load it (kill -HUP <bus-pid>), then on the node:\n` +
168
+ ` AGENT_COORD_TOKEN=${token} scripts/coord-node.sh --server <url> --id ${id} --cmd claude\n`,
169
+ );
170
+ }
96
171
  process.stdout.write(token + "\n"); // token alone on stdout, pipe/capture-friendly
97
172
  break;
98
173
  }
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env bash
2
- # stop-agent.sh — tear down an agent spawned by spawn-agent.sh.
3
- # Kills the tmux session and the tmux-pusher daemon.
2
+ # stop-agent.sh — tear down a `coord-<id>` tmux session and its `pusher-<id>.pid`.
3
+ #
4
+ # ⟨q-ec020f6a⟩: spawn-agent.sh (the LOCAL tmux-push spawner) is gone, but this script is
5
+ # generic over the pid-file/session-name convention, not over which pusher wrote it — and
6
+ # coord-node.sh (the REMOTE onboarding path, still fully supported) uses the same
7
+ # convention and documents this script as its own teardown. Kept for that reason.
4
8
  #
5
9
  # Usage:
6
- # scripts/stop-agent.sh --id frontend
7
- # scripts/stop-agent.sh --id frontend --dir /custom/coord/dir
10
+ # scripts/stop-agent.sh --id worker-2
11
+ # scripts/stop-agent.sh --id worker-2 --dir /custom/coord/dir
8
12
 
9
13
  set -euo pipefail
10
14
 
@@ -48,7 +48,7 @@ import { injectLine } from "../hooks/tier.mjs";
48
48
  import { findControlByte } from "../hooks/control-bytes.mjs";
49
49
  // ⟨q-15d763dc⟩ The ready-box guard's profile, as this process read it from its own env at start.
50
50
  // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
51
- import { readyProfile } from "../hooks/submit.mjs";
51
+ import { readyProfile, readReadyBox } from "../hooks/submit.mjs";
52
52
  import { subscriptionHealth } from "./tools/events.js";
53
53
  import {
54
54
  configuredTransport,
@@ -65,7 +65,7 @@ import { detectSpread } from "./server-spread.js";
65
65
  import { answeringServerIdentity } from "./tools/registry.js";
66
66
  import { HerdrTransport, herdrKeyName } from "./transports/herdr.js";
67
67
  import { tickVerdict } from "./tools/tick.js";
68
- import { ensureHerdrTail, herdrTailState, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
68
+ import { ensureHerdrTail, herdrTailState, newContext, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
69
69
  import { HERDR, TICK_READS_AS, TICK_STORED_AS, type TickState } from "./transports/types.js";
70
70
 
71
71
  export type ProbeResult = {
@@ -349,6 +349,77 @@ const PROBES: Probe[] = [
349
349
  }
350
350
  },
351
351
  },
352
+ {
353
+ // ⛔⛔ ⟨q-cdb5b007⟩ — the HTTP daemon started NO tails at all, so any seat one message behind
354
+ // was permanently and silently deaf. This probe exists because the PREVIOUS probe could not
355
+ // see that: it starts a tail by calling `ensureHerdrTail` directly, which is exactly what
356
+ // production never did under token auth. A capability that only the test calls is not present.
357
+ //
358
+ // Probed here: a tail started the way `startTailOnBind` now starts it types NOTHING that
359
+ // predates it. That is the half a seat can check about ITSELF; whether this build's auth path
360
+ // reaches it is reported at runtime under `herdrTail`, which must be non-empty on a live
361
+ // daemon. Label and code disagree by design — `versionLabel` reads package.json on disk.
362
+ id: "http-prebound-starts-tail-at-eof",
363
+ since: "0.26.26",
364
+ run: () => {
365
+ const probeId = `__probe-tail-eof-${process.pid}`;
366
+ const dir = mkdtempSync(path.join(tmpdir(), "probe-tail-eof-"));
367
+ try {
368
+ const seeded = newContext(true);
369
+ const inherited = newContext(false);
370
+ const hasSeedFields = typeof seeded.seedEof === "boolean" && seeded.seeded instanceof Set;
371
+ // A seeded context adopts EOF on first sight of a source; an inherited one does not.
372
+ const scripted = { kind: HERDR } as unknown as NonNullable<Parameters<typeof ensureHerdrTail>[1]>["transport"];
373
+ const seededStart = ensureHerdrTail(probeId, {
374
+ transport: scripted,
375
+ pollMs: 3_600_000,
376
+ seedEof: true,
377
+ tail: async () => ({ delivered: [], held: [] }),
378
+ });
379
+ const running = herdrTailState().some((s) => s.agentId === probeId && s.running);
380
+ const present = hasSeedFields && seeded.seedEof === true && inherited.seedEof === false && seededStart.started && running;
381
+ return {
382
+ present,
383
+ evidence: `newContext(true).seedEof=${seeded.seedEof} · newContext(false).seedEof=${inherited.seedEof} · per-source seeded set=${seeded.seeded instanceof Set} · ensureHerdrTail({seedEof}) started=${seededStart.started} running=${running} -> present=${present}`,
384
+ };
385
+ } finally {
386
+ stopHerdrTail(probeId);
387
+ rmSync(dir, { recursive: true, force: true });
388
+ }
389
+ },
390
+ },
391
+ {
392
+ // ⛔⛔ ⟨q-c8032c5f⟩ — the ready-box guard HELD EVERY PUSH to a live seat (ticks 191,
393
+ // delivered 0, held 98) reporting "the ❯ line has no ─ rule directly above it" against a pane
394
+ // that WAS ready. herdr splices the pane's own label into the frame, so the rule Claude Code
395
+ // drew as plain dashes arrives as `──… qa2 ─`. `RULE_RE` demanded an unbroken dash line and
396
+ // read the annotated one as not-a-rule: a false negative in the matcher, reported as a fact
397
+ // about the pane. The seat stayed queued and functional for PULL and simply never WOKE.
398
+ //
399
+ // ⚠ THE NEGATIVES ARE THE PROBE. Accepting the label is one line; accepting it WITHOUT opening
400
+ // a false-accept is the property. A matcher that took any annotated rule would pass a
401
+ // positive-only probe while typing into dialogs and menus — so a multi-word label, a label
402
+ // with no dash after it, and two labels are each asserted REFUSED alongside the accept.
403
+ id: "ready-box-tolerates-pane-label",
404
+ since: "0.26.27",
405
+ run: () => {
406
+ const D = "─".repeat(60);
407
+ const screen = (rule: string) => [`${D} above ${D}`, rule, "❯", D, " ⏵⏵ bypass permissions on"].join("\n");
408
+ const readyOf = (rule: string) => readReadyBox(screen(rule), "claude-code") as { ready: boolean; reason?: string };
409
+
410
+ const labelled = readyOf(`${D} qa2 ${D}`).ready === true; // the live failure, now accepted
411
+ const plain = readyOf(D).ready === true; // control: the un-annotated rule must still work
412
+ const multiWord = readyOf(`${D} two words ${D}`).ready === false;
413
+ const noTrailingDash = readyOf(`${D} qa2 `).ready === false;
414
+ const twoLabels = readyOf(`${D} a ${D} b ${D}`).ready === false;
415
+
416
+ const present = labelled && plain && multiWord && noTrailingDash && twoLabels;
417
+ return {
418
+ present,
419
+ evidence: `herdr-labelled rule accepted=${labelled} · plain rule still accepted=${plain} · REFUSED: multi-word=${multiWord}, no-trailing-dash=${noTrailingDash}, two-labels=${twoLabels} -> present=${present}`,
420
+ };
421
+ },
422
+ },
352
423
  {
353
424
  // ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
354
425
  // decides what a reading MEANS (every probe here is synchronous, so the async read is
@@ -495,8 +566,8 @@ export type TransportCapability = {
495
566
  agrees: boolean;
496
567
  /** What was called and what came back. */
497
568
  evidence: string;
498
- /** Where `configured` came from — config file, env, or the built-in default. */
499
- configuredSource: "config" | "env" | "default";
569
+ /** Where `configured` came from — config file or env. ⟨q-ec020f6a⟩: no built-in default. */
570
+ configuredSource: "config" | "env";
500
571
  /**
501
572
  * MIXED FLEET, MADE LOUD (3.4). Agents whose marker names a transport other
502
573
  * than the running one. Whole-fleet is the rule; this is the code noticing
package/src/server.ts CHANGED
@@ -845,9 +845,8 @@ async function main() {
845
845
  // branching in send_command or attach.
846
846
  try {
847
847
  const t = initTransportFromConfig();
848
- if (t.source !== "default") {
849
- console.error(`[agent-coord-mcp] transport: ${t.kind} (from ${t.source})`);
850
- }
848
+ // ⟨q-ec020f6a⟩: no built-in default any more — every resolution has a real source, always logged.
849
+ console.error(`[agent-coord-mcp] transport: ${t.kind} (from ${t.source})`);
851
850
  // ⛔ A HERDR SEAT MUST TAIL ITS OWN INBOX, OR IT IS DEAF TO EVERY TMUX SENDER.
852
851
  // The send-time path can only see the SENDER's transport (a module-level singleton), so a
853
852
  // tmux seat's server can never type into a herdr pane. The duty belongs to the seat that
@@ -1077,6 +1076,16 @@ async function startHttp(port: number): Promise<void> {
1077
1076
  const tokenMap = getTokenMap();
1078
1077
  if (tokenMap) {
1079
1078
  const agent = tokenMap.get(bearer);
1079
+ // ⛔ THE ONLY PLACE A PRE-BOUND SEAT CAN GET A TAIL. The `startTailOnBind` sites in the tool
1080
+ // path all sit behind `bound === undefined` — the TOFU claim. Here identity arrives already
1081
+ // decided by the bearer, that branch is unreachable, and before this line NO tail ever
1082
+ // started under HTTP (⟨q-cdb5b007⟩: `herdrTail: []`, and send-time delivery deferring to a
1083
+ // tail that does not exist drops the message permanently and silently).
1084
+ //
1085
+ // Deliberately lazy rather than a loop over the whole token map at boot: only a seat that
1086
+ // actually connects gets a tail, so a 20-agent map does not spawn 20 timers for seats that
1087
+ // may not exist. Idempotent — steady state is one Map lookup per request.
1088
+ if (agent) startTailOnBind(agent);
1080
1089
  return agent ? { ok: true, agent } : { ok: false };
1081
1090
  }
1082
1091
  // Advisory mode: only check the shared bearer matches.
@@ -63,8 +63,18 @@ export type TailContext = {
63
63
  hw: Map<string, number>;
64
64
  /** Last seen size per source: a source that has not grown costs one stat. */
65
65
  sizes: Map<string, number>;
66
+ /**
67
+ * ⛔ START AT EOF, NEVER AT WHATEVER THE CURSOR FILE HAPPENS TO SAY. A tail is a LIVE push
68
+ * mechanism, not a mail reader: nothing that predates its start belongs on the pane. Measured
69
+ * 2026-09-17 — a daemon restart reset every push cursor to ~0, so a tail starting from disk
70
+ * state would have typed a 3.4MB inbox into one seat's pane. `read_messages` still serves that
71
+ * history on demand; that is the verb for it.
72
+ */
73
+ seedEof: boolean;
74
+ /** Sources already seeded — a room discovered on a later refresh is seeded on ITS first sight, not skipped. */
75
+ seeded: Set<string>;
66
76
  };
67
- export const newContext = (): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map() });
77
+ export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map(), seedEof, seeded: new Set() });
68
78
 
69
79
  const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
70
80
  const sizeOf = (file: string): number => {
@@ -127,6 +137,15 @@ export async function tailOnce(
127
137
  // ⭐ THE IDLE PATH: a source that has not grown since the last tick costs this one stat.
128
138
  if (ctx.sizes.get(key) === size) continue;
129
139
  ctx.sizes.set(key, size);
140
+ // ⛔ FIRST SIGHT OF A SOURCE UNDER seedEof: adopt EOF and type NOTHING. This is the only place
141
+ // the starting offset is decided, and it is decided EXPLICITLY rather than inherited from a
142
+ // cursor file that a restart may have reset. Per source, so a room joined later seeds on its
143
+ // own first sight instead of replaying its history.
144
+ if (ctx.seedEof && !ctx.seeded.has(key)) {
145
+ ctx.seeded.add(key);
146
+ ctx.hw.set(key, size);
147
+ continue;
148
+ }
130
149
  const from = await offsetOf(agentId, src, ctx);
131
150
  if (size <= from) continue;
132
151
 
@@ -208,12 +227,12 @@ export function stopAllHerdrTails(): void {
208
227
  */
209
228
  export function ensureHerdrTail(
210
229
  agentId: string,
211
- opts: { transport?: Transport; pollMs?: number; tail?: (id: string, ctx: TailContext) => Promise<TailOutcome> } = {},
230
+ opts: { transport?: Transport; pollMs?: number; seedEof?: boolean; tail?: (id: string, ctx: TailContext) => Promise<TailOutcome> } = {},
212
231
  ): { started: boolean; running: boolean; why: string } {
213
232
  const t = opts.transport ?? activeTransport();
214
233
  if (!t || t.kind !== HERDR) return { started: false, running: false, why: `transport is ${t?.kind ?? "unwired"}, not herdr — a tmux seat is woken by its pusher` };
215
234
  if (running.has(agentId)) return { started: false, running: true, why: "already tailing this agent in this process" };
216
- const ctx = newContext();
235
+ const ctx = newContext(opts.seedEof === true);
217
236
  const run = opts.tail ?? ((id: string, c: TailContext) => tailOnce(id, { transport: t, ctx: c }));
218
237
  const rec: TailRecord = { agentId, startedAt: new Date().toISOString(), ticks: 0, delivered: 0, held: 0, lastTickAt: null, lastWhy: null, stop: () => {} };
219
238
  let inFlight = false;
@@ -237,8 +256,21 @@ export function ensureHerdrTail(
237
256
  return { started: true, running: true, why: `tailing ${agentId}'s inbox and rooms every ${opts.pollMs ?? DEFAULT_POLL_MS}ms` };
238
257
  }
239
258
 
240
- /** The hook the server calls at EVERY identity binding — `join`, a gated first claim, or the env var. */
259
+ /**
260
+ * The hook the server calls at EVERY identity binding — `join`, a gated first claim, the env var,
261
+ * or (HTTP) an authenticated request from a PRE-BOUND identity.
262
+ *
263
+ * ⛔ THE BINDING TRANSITION IS NOT REACHABLE UNDER HTTP, WHICH IS WHY THIS ALSO HANGS OFF AUTH.
264
+ * The `bound === undefined` sites below only fire when a session CLAIMS an id. A token-authenticated
265
+ * daemon resolves identity per request and reports `pre-bound (N agents)`, so `bound` is never
266
+ * undefined and NO tail ever started — measured 2026-09-17 as `herdrTail: []` on a freshly
267
+ * restarted daemon whose seats had re-joined after it. With the send-time path deferring owed
268
+ * messages to "the recipient's own tail", a tail that never exists makes that deferral a permanent
269
+ * silent drop: one message behind and the seat is deaf for good (⟨q-cdb5b007⟩).
270
+ *
271
+ * Idempotent, so the per-request call costs one Map lookup once the tail is up.
272
+ */
241
273
  export function startTailOnBind(agentId: string, log: (line: string) => void = (l) => console.error(l)): void {
242
- const r = ensureHerdrTail(agentId);
274
+ const r = ensureHerdrTail(agentId, { seedEof: true });
243
275
  if (r.started) log(`[agent-coord-mcp] herdr tail: ${r.why}`);
244
276
  }