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.
- package/README.md +31 -70
- package/dist/capabilities.js +72 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +13 -3
- package/dist/server.js.map +1 -1
- package/dist/tools/herdr-tail.js +26 -4
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/transport.js +84 -158
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/config.js +22 -11
- package/dist/transports/config.js.map +1 -1
- package/dist/transports/herdr.js +16 -0
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +3 -2
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/tmux.js +5 -2
- package/dist/transports/tmux.js.map +1 -1
- package/dist/transports/types.js +18 -3
- package/dist/transports/types.js.map +1 -1
- package/hooks/submit.mjs +12 -1
- package/package.json +4 -2
- package/scripts/check-global-mcp-fallback.mjs +85 -0
- package/scripts/coord-seat.mjs +96 -0
- package/scripts/coord-token.mjs +79 -4
- package/scripts/stop-agent.sh +8 -4
- package/src/capabilities.ts +75 -4
- package/src/server.ts +12 -3
- package/src/tools/herdr-tail.ts +37 -5
- package/src/tools/transport.ts +76 -162
- package/src/transports/config.ts +25 -12
- package/src/transports/herdr.ts +18 -0
- package/src/transports/index.ts +3 -2
- package/src/transports/tmux.ts +5 -2
- package/src/transports/types.ts +19 -4
- package/hooks/tmux-pusher.mjs +0 -971
- package/scripts/spawn-agent.sh +0 -94
package/dist/transports/types.js
CHANGED
|
@@ -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 = [
|
|
45
|
+
export const TRANSPORT_KINDS = [TMUX_PUSH_REMOTE, HERDR];
|
|
33
46
|
/**
|
|
34
|
-
* The tmux family
|
|
35
|
-
*
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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();
|
package/scripts/coord-token.mjs
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
}
|
package/scripts/stop-agent.sh
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# stop-agent.sh — tear down
|
|
3
|
-
#
|
|
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
|
|
7
|
-
# scripts/stop-agent.sh --id
|
|
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
|
|
package/src/capabilities.ts
CHANGED
|
@@ -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
|
|
499
|
-
configuredSource: "config" | "env"
|
|
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
|
-
|
|
849
|
-
|
|
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.
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
}
|