agent-coord-mcp 0.13.0 → 0.15.0

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.
@@ -171,6 +171,24 @@ function readJsonl(file) {
171
171
  // sender can't smuggle an arbitrary slash command past the guard below.
172
172
  const CONTROL_COMMANDS = new Set(["/clear", "/compact"]);
173
173
 
174
+ // Shells we refuse to inject into: if the pane's foreground command is one of
175
+ // these, the agent CLI has exited and typing would run pasted text as commands.
176
+ const SHELL_COMMANDS = new Set(["bash", "zsh", "sh", "fish", "dash", "ksh"]);
177
+
178
+ // Foreground command of the target pane, or null if it can't be determined
179
+ // (in which case we do NOT block — fall back to the bracketed-paste protection).
180
+ function paneCurrentCommand() {
181
+ const r = spawnSync("tmux", [
182
+ "display-message",
183
+ "-p",
184
+ "-t",
185
+ TMUX_TARGET,
186
+ "#{pane_current_command}",
187
+ ]);
188
+ if (r.status !== 0) return null;
189
+ return (r.stdout ?? "").toString().trim() || null;
190
+ }
191
+
174
192
  function isControl(m) {
175
193
  return (
176
194
  !!m &&
@@ -328,17 +346,28 @@ async function flush() {
328
346
  // its own as a RAW line so the TUI runs it as a slash command — a banner or
329
347
  // `[DM …]` prefix would turn it into plain chat text instead.
330
348
  async function injectViaTmux(batch) {
349
+ // Fail-closed pane guard: never type into a pane that has dropped to a shell
350
+ // (crashed/exited agent CLI). Bracketed paste protects a compliant TUI, but a
351
+ // raw shell would execute pasted lines — so refuse and let the at-least-once
352
+ // cursor redeliver once the agent CLI is back.
353
+ const cmd = paneCurrentCommand();
354
+ if (cmd !== null && SHELL_COMMANDS.has(cmd)) {
355
+ process.stderr.write(
356
+ `[tmux-pusher] pane '${TMUX_TARGET}' is at a shell ('${cmd}'), not the agent CLI — skipping inject (will redeliver)\n`,
357
+ );
358
+ return;
359
+ }
331
360
  let run = [];
332
361
  const flushRun = async () => {
333
362
  if (run.length === 0) return;
334
- await pasteAndSubmit(formatBatch(run));
363
+ await pasteAndSubmit(formatBatch(run), true); // peer content: inert bracketed paste
335
364
  writeReceipts(run); // stamp only AFTER the paste+submit resolves
336
365
  run = [];
337
366
  };
338
367
  for (const m of batch) {
339
368
  if (isControl(m)) {
340
369
  await flushRun();
341
- await pasteAndSubmit(m.text.trim());
370
+ await pasteAndSubmit(m.text.trim(), false); // validated control: raw slash command
342
371
  writeReceipts([m]);
343
372
  } else {
344
373
  run.push(m);
@@ -373,7 +402,11 @@ function writeReceipts(msgs) {
373
402
  }
374
403
  }
375
404
 
376
- function pasteAndSubmit(payload) {
405
+ // bracketed=true wraps the paste in bracketed-paste markers (paste-buffer -p) so
406
+ // a compliant TUI treats the payload as inert data — embedded newlines can't
407
+ // submit lines or smuggle a "/command". Control commands (/clear, /compact) must
408
+ // paste RAW (bracketed=false) so the TUI still runs them as slash commands.
409
+ function pasteAndSubmit(payload, bracketed = false) {
377
410
  return new Promise((resolve, reject) => {
378
411
  const load = spawn("tmux", ["load-buffer", "-b", BUFFER_NAME, "-"]);
379
412
  load.on("error", reject);
@@ -381,6 +414,7 @@ function pasteAndSubmit(payload) {
381
414
  if (code !== 0) return reject(new Error(`tmux load-buffer exit ${code}`));
382
415
  const paste = spawnSync("tmux", [
383
416
  "paste-buffer",
417
+ ...(bracketed ? ["-p"] : []),
384
418
  "-b",
385
419
  BUFFER_NAME,
386
420
  "-t",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
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": {
@@ -762,7 +762,7 @@ async function postStatus(status) {
762
762
  async function runDoctor(fix, emit) {
763
763
  let doctorTool;
764
764
  try {
765
- ({ doctorTool } = await import(new URL("../dist/tools.js", import.meta.url)));
765
+ ({ doctorTool } = await import(new URL("../dist/tools/index.js", import.meta.url)));
766
766
  } catch (e) {
767
767
  emit(A.red(`/doctor unavailable: could not load the doctor tool (${e?.message ?? e}).`));
768
768
  emit(A.dim(" This build may be missing dist/ — run `npm run build` in the package."));
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # coord-node.sh — one-command onboarding for a REMOTE machine to join the coord bus.
3
+ #
4
+ # The networked counterpart to spawn-agent.sh. spawn-agent.sh wires a LOCAL agent
5
+ # to a bus on the same box (hooks/tmux-pusher.mjs, reads the filesystem);
6
+ # coord-node wires a REMOTE agent to a networked bus over HTTP
7
+ # (scripts/coord-pusher.mjs). It:
8
+ # 1. starts a tmux session running your agent CLI,
9
+ # 2. auto-resolves that pane's tmux target,
10
+ # 3. launches the coord-pusher daemon pointed at the bus with this node's token.
11
+ #
12
+ # MVP daemon: per-node token. The operator mints a per-agent entry in the bus's
13
+ # tokens.json and passes that token here (single-use enrollment tokens + mTLS are
14
+ # a later step). It reuses spawn-agent.sh's tmux/pid conventions, so
15
+ # `scripts/stop-agent.sh --id <id>` also tears a coord-node down.
16
+ #
17
+ # Usage:
18
+ # scripts/coord-node.sh --server <url> --token <tok> --id <agent-id> \
19
+ # [--cmd 'claude'] [--allowlist a,b] [--no-room] [--dir <coord-dir>]
20
+ #
21
+ # Prefer the AGENT_COORD_TOKEN env var over --token so the secret never lands in
22
+ # your shell history or this script's argv:
23
+ # AGENT_COORD_TOKEN=tk_abc scripts/coord-node.sh --server https://bus:8765/mcp --id worker-2
24
+
25
+ set -euo pipefail
26
+
27
+ SERVER="${AGENT_COORD_SERVER:-}"
28
+ TOKEN="${AGENT_COORD_TOKEN:-}"
29
+ ID=""
30
+ CMD="claude"
31
+ ALLOWLIST=""
32
+ NO_ROOM=""
33
+ COORD_DIR=""
34
+
35
+ # tolerate an optional leading 'up' verb for symmetry with future subcommands
36
+ [[ "${1:-}" == "up" ]] && shift
37
+
38
+ while [[ $# -gt 0 ]]; do
39
+ case "$1" in
40
+ --server) SERVER="$2"; shift 2 ;;
41
+ --token) TOKEN="$2"; shift 2 ;;
42
+ --id|--agent) ID="$2"; shift 2 ;;
43
+ --cmd) CMD="$2"; shift 2 ;;
44
+ --allowlist) ALLOWLIST="$2"; shift 2 ;;
45
+ --no-room) NO_ROOM=1; shift ;;
46
+ --dir) COORD_DIR="$2"; shift 2 ;;
47
+ -h|--help) sed -n '2,29p' "$0"; exit 0 ;;
48
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
49
+ esac
50
+ done
51
+
52
+ [[ -z "$SERVER" ]] && { echo "--server (or AGENT_COORD_SERVER) required" >&2; exit 2; }
53
+ [[ -z "$TOKEN" ]] && { echo "--token (or AGENT_COORD_TOKEN) required" >&2; exit 2; }
54
+ [[ -z "$ID" ]] && { echo "--id required" >&2; exit 2; }
55
+
56
+ command -v tmux >/dev/null || { echo "tmux not installed" >&2; exit 1; }
57
+ command -v node >/dev/null || { echo "node not installed" >&2; exit 1; }
58
+
59
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
60
+ PUSHER="$REPO_DIR/scripts/coord-pusher.mjs"
61
+ [[ -f "$PUSHER" ]] || { echo "missing $PUSHER" >&2; exit 1; }
62
+
63
+ ROOT="${COORD_DIR:-${AGENT_COORD_DIR:-$HOME/agent-coord}}"
64
+ LOGDIR="$ROOT/logs"
65
+ PIDDIR="$ROOT/pids"
66
+ mkdir -p "$LOGDIR" "$PIDDIR"
67
+
68
+ SESSION="coord-$ID"
69
+ PID_FILE="$PIDDIR/pusher-$ID.pid"
70
+
71
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
72
+ echo "node daemon already running for '$ID' (pid $(cat "$PID_FILE"))" >&2
73
+ exit 0
74
+ fi
75
+
76
+ if tmux has-session -t "$SESSION" 2>/dev/null; then
77
+ echo "tmux session '$SESSION' already exists — attach with: tmux attach -t $SESSION" >&2
78
+ else
79
+ tmux new-session -d -s "$SESSION" -n agent "$CMD"
80
+ fi
81
+
82
+ TARGET="$(tmux display-message -p -t "$SESSION:agent" '#{pane_id}')"
83
+ [[ -n "$TARGET" ]] || { echo "failed to resolve tmux pane id" >&2; exit 1; }
84
+
85
+ # Token travels via env, not argv, so it does not appear in `ps` for the daemon.
86
+ ARGS=(--server "$SERVER" --agent "$ID" --tmux "$TARGET")
87
+ [[ -n "$ALLOWLIST" ]] && ARGS+=(--allowlist "$ALLOWLIST")
88
+ [[ -n "$NO_ROOM" ]] && ARGS+=(--no-room)
89
+
90
+ AGENT_COORD_TOKEN="$TOKEN" nohup node "$PUSHER" "${ARGS[@]}" \
91
+ >> "$LOGDIR/pusher-$ID.log" 2>&1 &
92
+ echo $! > "$PID_FILE"
93
+
94
+ echo "coord-node '$ID' up:"
95
+ echo " agent: tmux attach -t $SESSION (running: $CMD)"
96
+ echo " bus: $SERVER"
97
+ echo " daemon: pid $(cat "$PID_FILE") log: $LOGDIR/pusher-$ID.log"
98
+ echo " stop: scripts/stop-agent.sh --id $ID"
@@ -128,6 +128,24 @@ let sending = false;
128
128
  // Allowlisted control commands (mirrors CONTROL_COMMANDS in src/tools.ts).
129
129
  const CONTROL_COMMANDS = new Set(["/clear", "/compact"]);
130
130
 
131
+ // Shells we refuse to inject into: if the pane's foreground command is one of
132
+ // these, the agent CLI has exited and typing would run pasted text as commands.
133
+ const SHELL_COMMANDS = new Set(["bash", "zsh", "sh", "fish", "dash", "ksh"]);
134
+
135
+ // Foreground command of the target pane, or null if it can't be determined
136
+ // (in which case we do NOT block — fall back to the bracketed-paste protection).
137
+ function paneCurrentCommand() {
138
+ const r = spawnSync("tmux", [
139
+ "display-message",
140
+ "-p",
141
+ "-t",
142
+ TMUX_TARGET,
143
+ "#{pane_current_command}",
144
+ ]);
145
+ if (r.status !== 0) return null;
146
+ return (r.stdout ?? "").toString().trim() || null;
147
+ }
148
+
131
149
  function isControl(m) {
132
150
  return (
133
151
  !!m &&
@@ -168,13 +186,23 @@ async function flush() {
168
186
  }
169
187
  }
170
188
 
189
+ // The per-message PARSE CONTRACT line — MUST stay byte-identical to
190
+ // hooks/tier.mjs's injectLine (agent harnesses parse from/room/text out of
191
+ // it). Compact form (v0.14.0): ` [<kind> <HH:MM> <from>] <text>`, kind drops
192
+ // the leading "room ", timestamp is HH:MM UTC, no "from=" label. This pusher
193
+ // is standalone (may deploy without hooks/), so the helper is duplicated
194
+ // rather than imported; test/tier.test.mjs locks both to the same shape.
195
+ function injectLine(m) {
196
+ const tag = String(m.kind ?? "").replace(/^room /, "");
197
+ const d = new Date(m.ts ?? 0);
198
+ const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
199
+ return ` [${tag} ${hhmm} ${m.from}] ${m.text ?? ""}`;
200
+ }
201
+
171
202
  function formatBatch(batch) {
172
- const lines = [
173
- "[agent-coord] incoming peer messages — already consumed from your inbox, do not call read_messages for them:",
174
- ];
203
+ const lines = ["[agent-coord] msgs (pre-consumed, don't re-read):"];
175
204
  for (const m of batch) {
176
- const ts = new Date(m.ts ?? Date.now()).toISOString();
177
- lines.push(` [${m.kind} ${ts} from=${m.from}] ${m.text ?? ""}`);
205
+ lines.push(injectLine(m));
178
206
  }
179
207
  return lines.join("\n");
180
208
  }
@@ -183,16 +211,27 @@ function formatBatch(batch) {
183
211
  // control command (/clear, /compact) goes in on its own as a RAW line so the
184
212
  // CLI runs it as a slash command rather than echoing it as chat text.
185
213
  async function injectViaTmux(batch) {
214
+ // Fail-closed pane guard: never type into a pane that has dropped to a shell
215
+ // (crashed/exited agent CLI). Bracketed paste protects a compliant TUI, but a
216
+ // raw shell would execute pasted lines — so refuse and let the at-least-once
217
+ // cursor redeliver once the agent CLI is back.
218
+ const cmd = paneCurrentCommand();
219
+ if (cmd !== null && SHELL_COMMANDS.has(cmd)) {
220
+ process.stderr.write(
221
+ `[coord-pusher] pane '${TMUX_TARGET}' is at a shell ('${cmd}'), not the agent CLI — skipping inject (will redeliver)\n`,
222
+ );
223
+ return;
224
+ }
186
225
  let run = [];
187
226
  const flushRun = async () => {
188
227
  if (run.length === 0) return;
189
- await pasteAndSubmit(formatBatch(run));
228
+ await pasteAndSubmit(formatBatch(run), true); // peer content: inert bracketed paste
190
229
  run = [];
191
230
  };
192
231
  for (const m of batch) {
193
232
  if (isControl(m)) {
194
233
  await flushRun();
195
- await pasteAndSubmit(m.text.trim());
234
+ await pasteAndSubmit(m.text.trim(), false); // validated control: raw slash command
196
235
  } else {
197
236
  run.push(m);
198
237
  }
@@ -200,13 +239,17 @@ async function injectViaTmux(batch) {
200
239
  await flushRun();
201
240
  }
202
241
 
203
- function pasteAndSubmit(payload) {
242
+ // bracketed=true wraps the paste in bracketed-paste markers (paste-buffer -p) so
243
+ // a compliant TUI treats the payload as inert data — embedded newlines can't
244
+ // submit lines or smuggle a "/command". Control commands (/clear, /compact) must
245
+ // paste RAW (bracketed=false) so the TUI still runs them as slash commands.
246
+ function pasteAndSubmit(payload, bracketed = false) {
204
247
  return new Promise((resolve, reject) => {
205
248
  const load = spawn("tmux", ["load-buffer", "-b", BUFFER_NAME, "-"]);
206
249
  load.on("error", reject);
207
250
  load.on("exit", (code) => {
208
251
  if (code !== 0) return reject(new Error(`tmux load-buffer exit ${code}`));
209
- const paste = spawnSync("tmux", ["paste-buffer", "-b", BUFFER_NAME, "-t", TMUX_TARGET, "-d"]);
252
+ const paste = spawnSync("tmux", ["paste-buffer", ...(bracketed ? ["-p"] : []), "-b", BUFFER_NAME, "-t", TMUX_TARGET, "-d"]);
210
253
  if (paste.status !== 0) return reject(new Error(`tmux paste-buffer: ${(paste.stderr ?? "").toString().trim()}`));
211
254
  const enter = spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
212
255
  if (enter.status !== 0) return reject(new Error(`tmux send-keys: ${(enter.stderr ?? "").toString().trim()}`));
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // coord-token — mint / list / revoke per-agent bearer tokens in tokens.json.
3
+ //
4
+ // Closes the operator side of low-barrier node onboarding: instead of
5
+ // hand-editing tokens.json (and getting its perms right), mint a token for a
6
+ // node in one command, then paste it into coord-node on that machine.
7
+ //
8
+ // Usage:
9
+ // scripts/coord-token.mjs add <agent-id> mint (or rotate) a token, print it
10
+ // scripts/coord-token.mjs list list agent ids (never prints tokens)
11
+ // scripts/coord-token.mjs revoke <agent-id> remove a token
12
+ // [--dir <coord-dir>] override AGENT_COORD_DIR
13
+ //
14
+ // tokens.json is created 0600 (and re-chmod'd on every write) so secrets are
15
+ // never group/other-readable. After add/revoke, SIGHUP the running bus to
16
+ // reload its token map (kill -HUP <bus-pid>).
17
+
18
+ import { randomBytes } from "node:crypto";
19
+ import {
20
+ readFileSync,
21
+ writeFileSync,
22
+ existsSync,
23
+ chmodSync,
24
+ mkdirSync,
25
+ } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import path from "node:path";
28
+
29
+ const argv = process.argv.slice(2);
30
+ function opt(name) {
31
+ const i = argv.indexOf(name);
32
+ return i >= 0 ? argv[i + 1] : undefined;
33
+ }
34
+ function die(msg) {
35
+ process.stderr.write(`coord-token: ${msg}\n`);
36
+ process.exit(1);
37
+ }
38
+
39
+ // Must match store.ts ROOT/TOKENS_FILE resolution exactly, or we'd write a file
40
+ // the bus never reads.
41
+ const ROOT =
42
+ opt("--dir") ??
43
+ process.env.AGENT_COORD_DIR ??
44
+ process.env.CLAUDE_COORD_DIR ??
45
+ path.join(homedir(), "agent-coord");
46
+ const TOKENS_FILE = path.join(ROOT, "tokens.json");
47
+
48
+ const AGENT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
49
+
50
+ function load() {
51
+ if (!existsSync(TOKENS_FILE)) return {};
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(readFileSync(TOKENS_FILE, "utf8"));
55
+ } catch (e) {
56
+ die(`${TOKENS_FILE} is not valid JSON: ${e.message}`);
57
+ }
58
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
59
+ die(`${TOKENS_FILE} must be a JSON object of { "agent-id": "token" }`);
60
+ }
61
+ return parsed;
62
+ }
63
+
64
+ function save(map) {
65
+ mkdirSync(ROOT, { recursive: true, mode: 0o700 });
66
+ writeFileSync(TOKENS_FILE, JSON.stringify(map, null, 2) + "\n", {
67
+ mode: 0o600,
68
+ });
69
+ chmodSync(TOKENS_FILE, 0o600); // enforce even when the file pre-existed looser
70
+ }
71
+
72
+ const positional = argv.filter((a) => !a.startsWith("--"));
73
+ const cmd = positional[0];
74
+ const id = positional[1];
75
+
76
+ switch (cmd) {
77
+ case "add": {
78
+ if (!id || !AGENT_ID_RE.test(id)) {
79
+ die("usage: coord-token add <agent-id> (id: [a-zA-Z0-9._-], <=64 chars)");
80
+ }
81
+ const map = load();
82
+ const rotating = id in map;
83
+ const token = "tk_" + randomBytes(24).toString("base64url");
84
+ // Guard the astronomically unlikely collision that would collapse two
85
+ // identities onto one token in the bus's reverse map.
86
+ if (Object.values(map).includes(token)) die("token collision — rerun");
87
+ map[id] = token;
88
+ save(map);
89
+ process.stderr.write(
90
+ `${rotating ? "rotated" : "minted"} token for '${id}' in ${TOKENS_FILE}\n`,
91
+ );
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
+ );
96
+ process.stdout.write(token + "\n"); // token alone on stdout, pipe/capture-friendly
97
+ break;
98
+ }
99
+ case "list": {
100
+ const ids = Object.keys(load());
101
+ if (ids.length === 0) {
102
+ process.stderr.write("no tokens configured\n");
103
+ break;
104
+ }
105
+ for (const a of ids) process.stdout.write(a + "\n");
106
+ break;
107
+ }
108
+ case "revoke": {
109
+ if (!id) die("usage: coord-token revoke <agent-id>");
110
+ const map = load();
111
+ if (!(id in map)) die(`no token for '${id}'`);
112
+ delete map[id];
113
+ save(map);
114
+ process.stderr.write(`revoked '${id}'. SIGHUP the bus to apply.\n`);
115
+ break;
116
+ }
117
+ default:
118
+ process.stderr.write(
119
+ "coord-token — per-agent bus tokens\n" +
120
+ " coord-token add <agent-id> mint/rotate a token (prints it)\n" +
121
+ " coord-token list list agent ids\n" +
122
+ " coord-token revoke <agent-id> remove a token\n" +
123
+ " [--dir <coord-dir>]\n",
124
+ );
125
+ process.exit(cmd ? 1 : 0);
126
+ }
package/src/server.ts CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  quitTool,
63
63
  waitForMessageSchema,
64
64
  waitForMessageTool,
65
- } from "./tools.js";
65
+ } from "./tools/index.js";
66
66
 
67
67
  function jsonResult(data: unknown) {
68
68
  return {
@@ -186,7 +186,7 @@ function buildServer(initialBound?: string): McpServer {
186
186
 
187
187
  server.tool(
188
188
  "send_message",
189
- "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. The 'from' field is enforced against the session's bound identity when binding is configured.",
189
+ "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. The 'from' field is enforced against the session's bound identity when binding is configured.",
190
190
  sendMessageSchema,
191
191
  gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
192
192
  );
@@ -221,7 +221,7 @@ function buildServer(initialBound?: string): McpServer {
221
221
 
222
222
  server.tool(
223
223
  "prune",
224
- "Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
224
+ "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.",
225
225
  pruneSchema,
226
226
  gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
227
227
  );
@@ -422,12 +422,41 @@ async function startHttp(port: number): Promise<void> {
422
422
  const bindAddr = process.env.AGENT_COORD_BIND ?? "127.0.0.1";
423
423
  const sharedExpected = sharedToken ? `Bearer ${sharedToken}` : null;
424
424
 
425
+ // Fail-closed network gate. A non-loopback bind is a real network listener, so
426
+ // it must have (a) enforced per-agent identity and (b) a secured transport. We
427
+ // refuse rather than warn: a shared/advisory token lets any node impersonate
428
+ // any agent, and plaintext leaks bearer tokens to anyone on the path.
429
+ const isLoopbackBind =
430
+ bindAddr === "127.0.0.1" || bindAddr === "localhost" || bindAddr === "::1";
431
+ if (!isLoopbackBind) {
432
+ if (!bound) {
433
+ console.error(
434
+ `[agent-coord-mcp] refusing to bind ${bindAddr} without per-agent tokens: a ` +
435
+ `shared/advisory token lets any node impersonate any agent. Create ` +
436
+ `~/agent-coord/tokens.json (per-agent, enforced identity) for network binds.`,
437
+ );
438
+ process.exit(1);
439
+ }
440
+ if (process.env.AGENT_COORD_INSECURE !== "1") {
441
+ console.error(
442
+ `[agent-coord-mcp] refusing plaintext bind to ${bindAddr}: bearer tokens would ` +
443
+ `travel in cleartext. Put the bus behind TLS or a private overlay ` +
444
+ `(Tailscale/WireGuard), then set AGENT_COORD_INSECURE=1 to acknowledge the ` +
445
+ `transport is secured out-of-band.`,
446
+ );
447
+ process.exit(1);
448
+ }
449
+ }
450
+
425
451
  // One transport+server pair per client session. The SDK exposes session
426
452
  // affinity via the `mcp-session-id` header: a new request without it is
427
453
  // an init (create new pair); follow-ups carry the id (look up the pair).
428
454
  // We cannot share one stateful transport across clients (it errors with
429
455
  // "Server already initialized"), and stateless mode rejects reuse.
430
456
  const sessions = new Map<string, StreamableHTTPServerTransport>();
457
+ // Which bound agent each session id belongs to, so a session can't be driven
458
+ // by a *different* bearer that merely presents its id (session hijack).
459
+ const sessionAgents = new Map<string, string | undefined>();
431
460
 
432
461
  async function makeSessionTransport(boundAgent?: string): Promise<StreamableHTTPServerTransport> {
433
462
  // `let` + explicit type lets the SDK callbacks close over the binding
@@ -435,10 +464,16 @@ async function startHttp(port: number): Promise<void> {
435
464
  let transport: StreamableHTTPServerTransport;
436
465
  transport = new StreamableHTTPServerTransport({
437
466
  sessionIdGenerator: () => randomUUID(),
438
- onsessioninitialized: (id: string) => { sessions.set(id, transport); },
467
+ onsessioninitialized: (id: string) => {
468
+ sessions.set(id, transport);
469
+ sessionAgents.set(id, boundAgent);
470
+ },
439
471
  });
440
472
  transport.onclose = () => {
441
- if (transport.sessionId) sessions.delete(transport.sessionId);
473
+ if (transport.sessionId) {
474
+ sessions.delete(transport.sessionId);
475
+ sessionAgents.delete(transport.sessionId);
476
+ }
442
477
  };
443
478
  const server = buildServer(boundAgent);
444
479
  await server.connect(transport);
@@ -487,6 +522,20 @@ async function startHttp(port: number): Promise<void> {
487
522
  // bearer's agent; anything else is a protocol error.
488
523
  const sid = req.headers["mcp-session-id"];
489
524
  let transport = typeof sid === "string" ? sessions.get(sid) : undefined;
525
+ // Re-bind check: a session is pinned to the agent whose bearer opened it.
526
+ // In bound mode, reject a request whose bearer resolves to a *different*
527
+ // agent than the session was created for — otherwise any valid token plus
528
+ // a leaked session id could drive that session's identity (session hijack).
529
+ if (
530
+ transport &&
531
+ tokenMap &&
532
+ typeof sid === "string" &&
533
+ sessionAgents.get(sid) !== resolved.agent
534
+ ) {
535
+ res.writeHead(403, { "Content-Type": "text/plain" });
536
+ res.end("session/identity mismatch\n");
537
+ return;
538
+ }
490
539
  if (!transport) {
491
540
  if (req.method !== "POST") {
492
541
  res.writeHead(400, { "Content-Type": "text/plain" });
package/src/store.ts CHANGED
@@ -44,6 +44,17 @@ export const ROOMS_DIR = path.join(ROOT, "rooms");
44
44
  export const ROOMS_FILE = path.join(ROOT, "rooms.json");
45
45
  export const DEFAULT_ROOM = "general";
46
46
 
47
+ // Cold storage (v0.15.0). Nothing on the bus is ever deleted by prune or
48
+ // compaction — aged-out entries are APPENDED here first, one file per room
49
+ // (general included, despite its live file being the legacy room.jsonl), plus
50
+ // status.jsonl and inbox/<agent>.jsonl. The server never reads these back;
51
+ // they exist for offline analysis of what happened in a room. Receipts are
52
+ // the one exception: delivery proofs carry no analysis value and are deleted.
53
+ export const ARCHIVE_DIR = path.join(ROOT, "archive");
54
+ export const ARCHIVE_ROOMS_DIR = path.join(ARCHIVE_DIR, "rooms");
55
+ export const ARCHIVE_INBOX_DIR = path.join(ARCHIVE_DIR, "inbox");
56
+ export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl");
57
+
47
58
  // Per-agent token map for identity-bound bus auth (v0.7.0). Shape on disk:
48
59
  // { "alice": "tk_<random-secret>", "bob": "tk_<another-secret>" }
49
60
  // HTTP transport reverse-looks-up the bearer to bind the session to an
@@ -339,6 +350,57 @@ export async function rewriteJsonl<T>(
339
350
  });
340
351
  }
341
352
 
353
+ // Archive destinations for each live JSONL stream.
354
+ export function archiveRoomFile(chan: string): string {
355
+ return path.join(ARCHIVE_ROOMS_DIR, `${sanitize(normalizeRoom(chan))}.jsonl`);
356
+ }
357
+
358
+ export function archiveInboxFile(agentId: string): string {
359
+ return path.join(ARCHIVE_INBOX_DIR, `${sanitize(agentId)}.jsonl`);
360
+ }
361
+
362
+ // Like rewriteJsonl, but entries failing the filter are appended to
363
+ // archiveFile instead of discarded. The archive append happens under the
364
+ // source-file lock and BEFORE the rewrite, so a crash mid-operation can at
365
+ // worst duplicate entries into the archive — never lose them. Malformed
366
+ // lines are archived verbatim rather than parsed.
367
+ export async function archiveJsonl<T>(
368
+ file: string,
369
+ archiveFile: string,
370
+ filter: (entry: T) => boolean
371
+ ): Promise<{ kept: number; removed: number; archived: number }> {
372
+ if (!existsSync(file)) return { kept: 0, removed: 0, archived: 0 };
373
+ return withLock(file, async () => {
374
+ const raw = await fs.readFile(file, "utf8");
375
+ let kept = 0;
376
+ let removed = 0;
377
+ const keep: string[] = [];
378
+ const archive: string[] = [];
379
+ for (const line of raw.split("\n")) {
380
+ if (!line.trim()) continue;
381
+ try {
382
+ const entry = JSON.parse(line) as T;
383
+ if (filter(entry)) {
384
+ keep.push(line);
385
+ kept++;
386
+ } else {
387
+ archive.push(line);
388
+ removed++;
389
+ }
390
+ } catch {
391
+ archive.push(line);
392
+ removed++;
393
+ }
394
+ }
395
+ if (archive.length) {
396
+ await fs.mkdir(path.dirname(archiveFile), { recursive: true });
397
+ await fs.appendFile(archiveFile, archive.join("\n") + "\n", "utf8");
398
+ }
399
+ await fs.writeFile(file, keep.length ? keep.join("\n") + "\n" : "", "utf8");
400
+ return { kept, removed, archived: archive.length };
401
+ });
402
+ }
403
+
342
404
  export async function deleteFile(file: string): Promise<boolean> {
343
405
  if (!existsSync(file)) return false;
344
406
  await fs.unlink(file);