agent-coord-mcp 0.26.25 → 0.26.26

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.
@@ -1,971 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * tmux-pusher.mjs
4
- *
5
- * Long-running daemon that watches an agent's coord inbox (and optionally the
6
- * shared room) and types new messages into a tmux pane running an interactive
7
- * CLI agent. Works with any line-driven agent CLI: Claude Code, Aider, codex,
8
- * gemini-cli, opencode, etc.
9
- *
10
- * Required env:
11
- * AGENT_COORD_ID agentId registered with the MCP
12
- * AGENT_COORD_TMUX_TARGET tmux target, e.g. "coord-frontend:agent.0"
13
- *
14
- * Optional env:
15
- * AGENT_COORD_DIR override state dir (default ~/agent-coord)
16
- * AGENT_COORD_INCLUDE_ROOM "1" to also inject shared-room messages
17
- * AGENT_COORD_ALLOWLIST comma-separated peer agentIds to accept
18
- * (default: accept all)
19
- * AGENT_COORD_DEBOUNCE_MS coalesce window for bursts (default 1000)
20
- * AGENT_COORD_POLL_MS fallback poll interval (default 1000)
21
- * AGENT_COORD_RETRY_MAX_MS backoff ceiling for retrying messages the pane
22
- * refused (default 60000)
23
- * AGENT_COORD_GIVEUP_AFTER consecutive failed flushes before push is
24
- * SUSPENDED and the agent is left to read its own
25
- * traffic via read_messages (default 5)
26
- * AGENT_COORD_TARGET_GRACE missed pane probes before self-exit (default 3)
27
- * AGENT_COORD_MAX_QUEUE_MS max time routine (channel) traffic may queue
28
- * without an urgent trigger before it flushes as
29
- * a digest (default 15000 = 15s; 0 disables)
30
- *
31
- * Safety:
32
- * - drops messages where from === AGENT_COORD_ID (no self-echo)
33
- * - drops messages whose text starts with "/" (avoid injected slash commands),
34
- * EXCEPT control-flagged messages carrying an allowlisted command
35
- * (/clear, /compact, /reload-skills) sent via the MCP `send_command` tool — those are
36
- * injected RAW (no banner/prefix) so the CLI runs them as slash commands
37
- * - if allowlist set, drops messages from peers not in it
38
- * - serializes tmux sends so two batches never overlap
39
- *
40
- * Cursor: shares ~/agent-coord/cursors/<id>.json with the MCP server, so the
41
- * agent calling read_messages won't see anything the pusher already delivered.
42
- * The cursor advances ONLY past messages the pane actually accepted: inject
43
- * reports back what it could not deliver, and a batch with any undelivered
44
- * message commits nothing and retries just that remainder (backing off to
45
- * AGENT_COORD_RETRY_MAX_MS). Delivery is therefore at-least-once, never
46
- * at-most-once — a refusing pane holds traffic, it does not drain it. That is
47
- * load-bearing: committing the batch whenever inject merely returned meant a
48
- * shell-parked pane, a slow paste, an unrecognised prompt or a lost Enter
49
- * silently discarded messages the sender had been told were delivered, and
50
- * read_messages could not see them either.
51
- *
52
- * A pane can also be PERMANENTLY unpushable — in the field that is the common
53
- * failure, not the rare one (one fleet: 1,001 of 1,004 push failures were a
54
- * single agent whose TUI never matched PROMPT_PATTERN). After
55
- * AGENT_COORD_GIVEUP_AFTER consecutive failures the pusher suspends and rewinds
56
- * to the on-disk cursor, so the agent simply reads its own inbox. Note the
57
- * division this rests on: receipts/ record whether a PUSH landed, cursors/
58
- * record whether the AGENT consumed. A clean receipt run is evidence of
59
- * delivery, never of receipt — an agent can be fully caught up with every push
60
- * to it having failed.
61
- *
62
- * Do NOT enable peek-coord.mjs Stop/UserPromptSubmit hooks for the same agent
63
- * while this daemon runs — both consume the same cursor and would race.
64
- *
65
- * Caveats:
66
- * - PEER MESSAGES paste into whatever pane state exists. If you're mid-typing
67
- * in the same pane, your buffer gets corrupted. Run the receiving agent in
68
- * a dedicated pane you don't normally edit in.
69
- * - CONTROL COMMANDS (/clear, /compact, /reload-skills) are the exception: since they paste
70
- * RAW, a pane that is busy or holds an unsent draft would either queue them
71
- * behind a running turn or append them to the draft and ship the result to
72
- * the model as ordinary text — both observed live, both reporting
73
- * delivery:"confirmed". So control submission is CONDITIONAL: it waits
74
- * (bounded) for a busy pane to go idle, refuses to paste onto a draft, and
75
- * verifies via capture-pane that the command left the input. An unverified
76
- * submission reports delivery:"pending" with a reason, never "confirmed".
77
- * See hooks/submit.mjs, shared with scripts/coord-pusher.mjs.
78
- * - CLEANUP: never `pkill -f tmux-pusher.mjs` — the pattern matches EVERY
79
- * pusher on the bus, and one agent's test-rig cleanup doing exactly that
80
- * silently detached all four live agents (2026-07-28). attach_agent puts
81
- * `--agent <id>` in argv so a pattern kill can be scoped to one agent
82
- * (`pkill -f "tmux-pusher.mjs --agent <id>"`); better still, use
83
- * detach_agent or the pid file — both target the exact pid.
84
- */
85
-
86
- import {
87
- existsSync,
88
- readFileSync,
89
- writeFileSync,
90
- appendFileSync,
91
- renameSync,
92
- mkdirSync,
93
- unlinkSync,
94
- watch,
95
- } from "node:fs";
96
- import { readdirSync, statSync } from "node:fs";
97
- import { homedir } from "node:os";
98
- import { fileURLToPath } from "node:url";
99
- import path from "node:path";
100
- import { spawn, spawnSync } from "node:child_process";
101
- import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.mjs";
102
- import { isCi } from "./roles.mjs";
103
- import { mergeTransportMarker } from "./marker.mjs";
104
- import { readPushCursor, writePushCursor } from "./push-cursor.mjs";
105
- import { priorDeliveries, replayInfo } from "./replay.mjs";
106
- import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl, readyProfileStartupLine } from "./submit.mjs";
107
-
108
- const AGENT_ID = process.env.AGENT_COORD_ID;
109
- const TMUX_TARGET = process.env.AGENT_COORD_TMUX_TARGET;
110
- if (!AGENT_ID) die("AGENT_COORD_ID is required");
111
- if (!TMUX_TARGET) die("AGENT_COORD_TMUX_TARGET is required");
112
-
113
- const ROOT = process.env.AGENT_COORD_DIR || path.join(homedir(), "agent-coord");
114
- const INCLUDE_ROOM = process.env.AGENT_COORD_INCLUDE_ROOM === "1";
115
- // Default channel + watch registry — declared up here so the hoisted channel
116
- // helpers, called from the top-level checkOnce(), don't trip the const TDZ.
117
- const DEFAULT_ROOM = "general";
118
- const watchedRooms = new Set();
119
- const ALLOWLIST = (process.env.AGENT_COORD_ALLOWLIST || "")
120
- .split(",")
121
- .map((s) => s.trim())
122
- .filter(Boolean);
123
- const DEBOUNCE_MS = parseInt(process.env.AGENT_COORD_DEBOUNCE_MS || "1000", 10);
124
- const POLL_MS = parseInt(process.env.AGENT_COORD_POLL_MS || "1000", 10);
125
- // Ceiling for the undelivered-retry backoff (see nextRetryDelay). A permanently
126
- // refusing pane must keep retrying — the traffic is held, not dropped — but at
127
- // a rate that costs one capture-pane a minute rather than one a second.
128
- const RETRY_MAX_MS = parseInt(process.env.AGENT_COORD_RETRY_MAX_MS || "60000", 10);
129
- // Consecutive missed pane probes before we conclude the target is gone and
130
- // self-exit. A small grace rides out transient tmux-server hiccups.
131
- const TARGET_GRACE = parseInt(process.env.AGENT_COORD_TARGET_GRACE || "3", 10);
132
- // Delivery tiers: only push-now traffic (BLOCKER/DAVID_DECISION/GO:/
133
- // trusted SCOPE:/DONE-to-gate, controls, server-flagged reminders) wakes the
134
- // agent; everything else queues unread and rides the next push as a coalesced
135
- // digest. AGENT_COORD_TIERS=0 restores legacy push-everything.
136
- const TIERS_ENABLED = process.env.AGENT_COORD_TIERS !== "0";
137
- // Max-age flush: routine traffic never waits longer than this for an urgent
138
- // trigger — once the oldest queued message is overdue, the backlog flushes as
139
- // a routine-only digest. Bounds the silent-fleet failure mode where FYI:/DONE:
140
- // chatter queues forever because nothing urgent ever arrives. Only channel
141
- // traffic queues (DMs are push-now), so the default is short.
142
- const MAX_QUEUE_MS = parseInt(process.env.AGENT_COORD_MAX_QUEUE_MS || "15000", 10);
143
- // Gate runners (QA/coordinator) receive DONE: as push-now; countersigned
144
- // SCOPE: changes are honored only from these trusted ids. Re-resolved from
145
- // the registry every 30s so a role change doesn't strand a stale pusher;
146
- // AGENT_COORD_GATE_RUNNER=1|0 overrides in both directions.
147
- let tierCtx = { enabled: TIERS_ENABLED, gateRunner: false, ci: false, trustedSenders: new Set() };
148
- function refreshTierCtx() {
149
- let gateRunner = false;
150
- let ci = false;
151
- const trusted = new Set();
152
- try {
153
- const reg = JSON.parse(readFileSync(path.join(ROOT, "agents.json"), "utf8"));
154
- for (const [id, entry] of Object.entries(reg ?? {})) {
155
- // Pass the whole entry: the frozen roleId lives beside `role`, and
156
- // reading only the display string would throw the identity away.
157
- if (isGateRunnerRole(entry)) trusted.add(id);
158
- if (id === AGENT_ID) ci = isCi(entry);
159
- }
160
- gateRunner = trusted.has(AGENT_ID);
161
- } catch {
162
- // No registry yet — conservative default (not a gate runner, not ci).
163
- }
164
- const env = process.env.AGENT_COORD_GATE_RUNNER;
165
- if (env === "1" || env === "true") gateRunner = true;
166
- if (env === "0" || env === "false") gateRunner = false;
167
- // AGENT_COORD_GATE_RUNNER must not flip ci — that would look like a
168
- // verdict grant. Express-lane wake is roleId === ci only.
169
- if (gateRunner !== tierCtx.gateRunner || ci !== tierCtx.ci) {
170
- process.stderr.write(`[tmux-pusher] gate-runner resolved: ${gateRunner} ci: ${ci}\n`);
171
- }
172
- tierCtx = { enabled: TIERS_ENABLED, gateRunner, ci, trustedSenders: trusted };
173
- }
174
- refreshTierCtx();
175
- setInterval(refreshTierCtx, 30_000).unref();
176
-
177
- const SAFE_ID = AGENT_ID.replace(/[^a-zA-Z0-9._-]/g, "_");
178
- const INBOX_FILE = path.join(ROOT, "inbox", `${SAFE_ID}.jsonl`);
179
- const CURSOR_FILE = path.join(ROOT, "cursors", `${SAFE_ID}.json`);
180
-
181
- /*
182
- * THE PUSH CURSOR IS NOT THE READ CURSOR.
183
- *
184
- * These were one file. The pusher advanced `roomOffsets[chan]` the moment a
185
- * paste was DEEMED submitted, and `read_messages` reads the same offsets — so a
186
- * paste that never reached the pane still marked the message consumed. The
187
- * message was then unreachable by every route: not in the pane, and skipped by
188
- * the reader.
189
- *
190
- * That is today's parent class — a failure path returning a plausible success —
191
- * with one difference that makes it worse than every other instance: this one
192
- * WRITES THE SUCCESS INTO DURABLE STATE. The others produced a wrong reading;
193
- * this destroys the evidence that the reading was wrong.
194
- *
195
- * Measured on a consumer fleet: an agent's cursor sat past a peer's
196
- * `DONE: merged #<n>` at line 1107 of that room's log — a ROOM post, so its
197
- * inbox was correctly empty and its `gh` current. Everything looked fine.
198
- *
199
- * Separating them does NOT make delivery reliable. It converts SILENT LOSS into
200
- * mere NON-DELIVERY: the message stays unread in the read cursor, so
201
- * `read_messages` still serves it and the failure becomes visible. Cheapest
202
- * first, and it is the half that stops data loss rather than a stall.
203
- *
204
- * The cost is duplication, deliberately: a message pasted into the pane may
205
- * also be served by `read_messages`. At-least-once with a visible duplicate
206
- * beats at-most-once with an invisible hole — the same ruling as the event
207
- * subscriptions' delivery contract.
208
- */
209
- const PUSH_CURSOR_FILE = path.join(ROOT, "cursors", `${SAFE_ID}.push.json`);
210
- const TRANSPORT_FILE = path.join(ROOT, "transports", `${SAFE_ID}.json`);
211
- // Out-of-band delivery receipts: stamped AFTER a message is typed into the
212
- // pane, so the sender can poll for proof of delivery without the receipt ever
213
- // entering any agent's context. Mirrors RECEIPTS_DIR in src/store.ts.
214
- const RECEIPTS_FILE = path.join(ROOT, "receipts", `${SAFE_ID}.jsonl`);
215
- const BUFFER_NAME = `coord-${SAFE_ID}`;
216
- // Newest mtime across this script AND its sibling hooks modules, sampled once
217
- // at startup — an upgrade to any of them afterwards leaves this value behind,
218
- // which is exactly what doctor's stale-pusher-script check compares against.
219
- // The entry file alone is not enough: control submission lives in submit.mjs
220
- // and tiering in tier.mjs/roles.mjs, so a fix touching only an import would
221
- // leave a single-file stamp unchanged and a still-running pusher on the old
222
- // code would read as fresh. Scanning the whole dir over-covers (a hooks file
223
- // we never import can flag us stale) — that is the safe direction: a spurious
224
- // re-attach is loud and cheap, a false green silently invalidates the rollout
225
- // verification of every pusher-side fix.
226
- const SCRIPT_MTIME = (() => {
227
- try {
228
- const dir = path.dirname(fileURLToPath(import.meta.url));
229
- let newest;
230
- for (const f of readdirSync(dir)) {
231
- if (!f.endsWith(".mjs")) continue;
232
- const m = statSync(path.join(dir, f)).mtimeMs;
233
- if (newest === undefined || m > newest) newest = m;
234
- }
235
- return newest;
236
- } catch {
237
- return undefined;
238
- }
239
- })();
240
-
241
- mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
242
- mkdirSync(path.dirname(TRANSPORT_FILE), { recursive: true });
243
- mkdirSync(path.dirname(RECEIPTS_FILE), { recursive: true });
244
-
245
- // Confirm tmux target exists at startup so we fail loudly instead of silently.
246
- // `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
247
- // DOES NOT — tmux exits 0 for any target, including a pane killed a moment
248
- // ago, so the probe had ZERO discriminating power and reported every dead
249
- // pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
250
- // tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
251
- // Positive control, both directions: bogus target -> has-session exit 1,
252
- // display-message exit 0; live pane -> both exit 0.
253
- const probe = spawnSync("tmux", ["has-session", "-t", TMUX_TARGET]);
254
- if (probe.status !== 0) {
255
- die(`tmux target '${TMUX_TARGET}' not found: ${(probe.stderr ?? "").toString().trim()}`);
256
- }
257
-
258
- let pending = [];
259
- let debounceTimer = null;
260
- let sending = false;
261
- let targetMisses = 0;
262
-
263
- // Cursor IO lives in ./push-cursor.mjs so it can be EXERCISED — inline here,
264
- // the only thing a test could reach was the source text.
265
- function readCursor() {
266
- return readPushCursor(ROOT, SAFE_ID);
267
- }
268
-
269
- function writeCursor(c) {
270
- writePushCursor(ROOT, SAFE_ID, c);
271
- }
272
-
273
- function readJsonl(file) {
274
- if (!existsSync(file)) return [];
275
- return readFileSync(file, "utf8")
276
- .split("\n")
277
- .filter(Boolean)
278
- .map((l) => {
279
- try {
280
- return JSON.parse(l);
281
- } catch {
282
- return null;
283
- }
284
- })
285
- .filter(Boolean);
286
- }
287
-
288
- // Allowlisted control commands the bus may type into this CLI. Mirrors
289
- // CONTROL_COMMANDS in src/tools/transport.ts and scripts/coord-pusher.mjs
290
- // (test/allowlist-parity.test.mjs locks the three); kept tiny + literal so a bugged/compromised
291
- // sender can't smuggle an arbitrary slash command onto the RAW submit path.
292
- // ⚠ THIS SET IS THE GATE. It used to be backed by a second check that dropped any
293
- // text starting with "/"; that check was removed in ⟨q-f14692ca⟩ because it silently
294
- // discarded legitimate messages and never saw the multi-line shape it was aimed at.
295
- // Membership here, plus bracketed paste on the peer path, is now the whole defence.
296
- const CONTROL_COMMANDS = new Set(["/clear", "/compact", "/reload-skills"]);
297
-
298
- // Shells we refuse to inject into: if the pane's foreground command is one of
299
- // these, the agent CLI has exited and typing would run pasted text as commands.
300
- const SHELL_COMMANDS = new Set(["bash", "zsh", "sh", "fish", "dash", "ksh"]);
301
-
302
- // Foreground command of the target pane, or null if it can't be determined
303
- // (in which case we do NOT block — fall back to the bracketed-paste protection).
304
- function paneCurrentCommand() {
305
- const r = spawnSync("tmux", [
306
- "display-message",
307
- "-p",
308
- "-t",
309
- TMUX_TARGET,
310
- "#{pane_current_command}",
311
- ]);
312
- if (r.status !== 0) return null;
313
- return (r.stdout ?? "").toString().trim() || null;
314
- }
315
-
316
- function isControl(m) {
317
- return (
318
- !!m &&
319
- m.control === true &&
320
- typeof m.text === "string" &&
321
- CONTROL_COMMANDS.has(m.text.trim())
322
- );
323
- }
324
-
325
- function shouldInject(m) {
326
- if (!m || m.from === AGENT_ID) return false;
327
- if (ALLOWLIST.length > 0 && !ALLOWLIST.includes(m.from)) return false;
328
- // Authorized control command — injected RAW later, so the allowlist in
329
- // `isControl` is the gate that matters. Checked first and unchanged.
330
- if (isControl(m)) return true;
331
-
332
- // ⛔ THE SLASH DROP USED TO LIVE HERE AND IT IS GONE ON PURPOSE (⟨q-f14692ca⟩).
333
- //
334
- // It returned false for any text starting with "/", which SILENTLY discarded
335
- // the message: the sender was told nothing, and `collectSource` advances the
336
- // offset past rejected lines, so it was never retried either. Measured in an
337
- // isolated pane: a plain message and a trailing message both arrived, the
338
- // slash-leading one between them did not, and the pusher log said nothing.
339
- //
340
- // AND IT WAS PROTECTING NOTHING THAT IS NOT ALREADY PROTECTED TWICE, measured
341
- // rather than reasoned:
342
- // 1. `formatBatch` prefixes every peer line with ` [DM hh:mm from] `, so a
343
- // single-line "/foo" reaches the pane with ZERO lines starting with "/" —
344
- // it is chat text, not a command.
345
- // 2. Ordinary batches paste with `bracketed = true`, which makes the whole
346
- // payload inert: embedded newlines cannot submit a line or smuggle a
347
- // "/command". That is the defence that actually covers the multi-line
348
- // case, which this check never caught — `trimStart()` only ever examined
349
- // the FIRST line, so "opener\n/clear" passed straight through it.
350
- //
351
- // So the old guard was wrong in both directions at once: it dropped safe
352
- // single-line messages, and it did not see the multi-line shape it was aimed
353
- // at. Removing it restores delivery; the protection that matters is the
354
- // allowlist above plus bracketed paste, and BOTH are pinned by tests in
355
- // test/slash-message-delivery.test.mjs. ⚠ If anyone makes the peer path paste
356
- // raw, those tests fail — which is the price of not having this check.
357
- return true;
358
- }
359
-
360
- // In-memory delivery ledger. `stagedOffsets` marks how far we've COLLECTED
361
- // (prevents double-collection across polls); the on-disk cursor — shared
362
- // with read_messages — advances only after a batch is actually pasted. A
363
- // crash/SIGTERM/pane-death between the two rewinds to the on-disk cursor, so
364
- // queued-but-undelivered traffic is redelivered on restart (at-least-once),
365
- // never lost. Routine messages wait in tierQueue until a push-now trigger
366
- // coalesces them; their offset commits travel in pendingCommits alongside.
367
- const stagedOffsets = {};
368
- const tierQueue = new TierQueue({ maxAgeMs: TIERS_ENABLED ? MAX_QUEUE_MS : 0 });
369
- let pendingCommits = [];
370
-
371
- function collectSource(label, file, cursorKey, cur, staged) {
372
- const all = readJsonl(file);
373
- const off = Math.max(cur[cursorKey] ?? 0, stagedOffsets[cursorKey] ?? 0);
374
- const fresh = all.slice(off);
375
- if (fresh.length === 0) return;
376
- for (const m of fresh) {
377
- if (shouldInject(m)) {
378
- // The channel tag lives in `tag`, not `kind`. A stored Message carries
379
- // its own `kind` ("decision"/"status"/"chatter" — retention weight);
380
- // sharing the name meant a room post tagged kind:"decision" (what the
381
- // README recommends for GOs and verdicts) overwrote the channel tag and
382
- // rendered as `[decision …]` instead of `[#general …]`, breaking
383
- // injectLine's parse contract. Distinct names remove the collision at
384
- // the source; assignment still follows the spread so a sender cannot
385
- // smuggle a `tag` in either.
386
- const tagged = { ...m, tag: label };
387
- // Assigned after the spread so a sender can't smuggle a tier field in.
388
- tagged.tier = effectiveTier(tagged, tierCtx);
389
- staged.msgs.push(tagged);
390
- }
391
- }
392
- const newOff = off + fresh.length;
393
- stagedOffsets[cursorKey] = newOff;
394
- // Offsets are absolute and applied monotonically, so re-applying on a
395
- // flush retry is harmless.
396
- staged.commits.push((c) => {
397
- c[cursorKey] = Math.max(c[cursorKey] ?? 0, newOff);
398
- });
399
- }
400
-
401
- // Collect one channel against its per-channel offset (general → roomOffset,
402
- // others → roomOffsets[chan]); tag collected lines with the channel name.
403
- function collectRoomChannel(chan, cur, staged) {
404
- const c = normalizeRoom(chan);
405
- const all = readJsonl(roomFile(c));
406
- const off = Math.max(getRoomOffset(cur, c), stagedOffsets[`room:${c}`] ?? 0);
407
- const fresh = all.slice(off);
408
- if (fresh.length === 0) return;
409
- for (const m of fresh) {
410
- if (shouldInject(m)) {
411
- const tagged = { ...m, tag: `room #${c}` }; // see channel-tag note above
412
- tagged.tier = effectiveTier(tagged, tierCtx);
413
- staged.msgs.push(tagged);
414
- }
415
- }
416
- const newOff = off + fresh.length;
417
- stagedOffsets[`room:${c}`] = newOff;
418
- staged.commits.push((cc) => setRoomOffset(cc, c, Math.max(getRoomOffset(cc, c), newOff)));
419
- }
420
-
421
- // Fold accumulated offset commits into the on-disk cursor. Called from the
422
- // flush success path (delivery confirmed) and for junk-only batches.
423
- function commitOffsets(commits) {
424
- if (commits.length === 0) return;
425
- const cur = readCursor();
426
- for (const c of commits) c(cur);
427
- writeCursor(cur);
428
- }
429
-
430
- function checkOnce() {
431
- // While suspended, collect NOTHING: the on-disk cursor is the sole record of
432
- // what this agent still owes, and read_messages is serving it. Collecting
433
- // would re-stage messages we cannot deliver, and — because offsets are
434
- // absolute and applied with Math.max — a single later success would leapfrog
435
- // the cursor past them and destroy exactly what the suspension protected.
436
- if (pushSuspended) {
437
- if (Date.now() - suspendedAt < RETRY_MAX_MS) return;
438
- resumePush();
439
- }
440
- const cur = readCursor();
441
- const staged = { msgs: [], commits: [] };
442
- collectSource("DM", INBOX_FILE, "inboxOffset", cur, staged);
443
- if (INCLUDE_ROOM) {
444
- // Tail every channel the agent has joined. checkOnce runs on each poll, so
445
- // channels joined after startup are picked up automatically.
446
- for (const chan of joinedRooms()) {
447
- collectRoomChannel(chan, cur, staged);
448
- watchRoom(chan);
449
- }
450
- }
451
- pendingCommits.push(...staged.commits);
452
- if (
453
- staged.msgs.length === 0 &&
454
- tierQueue.size() === 0 &&
455
- pending.length === 0 &&
456
- !sending &&
457
- pendingCommits.length > 0
458
- ) {
459
- // Everything fresh was filtered (self/allowlist/slash) and nothing is
460
- // queued or in flight — fold the offsets forward now instead of leaving
461
- // junk to block until a future push.
462
- commitOffsets(pendingCommits);
463
- pendingCommits = [];
464
- return;
465
- }
466
- // Tiered delivery: push only when the fresh batch contains a push-now
467
- // trigger. A routine-only backlog stays queued (on-disk cursor untouched)
468
- // so an idle agent is never woken — it rides the digest of the next trigger,
469
- // or flushes on its own once the oldest queued message exceeds MAX_QUEUE_MS.
470
- const now = Date.now();
471
- const batch = tierQueue.ingest(staged.msgs, now) ?? tierQueue.flushOverdue(now);
472
- if (!batch) return;
473
- pending.push(...batch);
474
- scheduleFlush();
475
- }
476
-
477
- function scheduleFlush(delayMs = DEBOUNCE_MS) {
478
- if (debounceTimer) return;
479
- debounceTimer = setTimeout(flush, delayMs);
480
- }
481
-
482
- // Backoff for the undelivered-retry path. A pane parked at a shell, or a TUI
483
- // whose prompt never matches PROMPT_PATTERN, fails EVERY attempt — retrying on
484
- // the debounce window would spin capture-pane forever. Grows to a cap and
485
- // resets the moment a batch lands whole.
486
- let retryDelay = 0;
487
- function nextRetryDelay() {
488
- retryDelay = retryDelay === 0 ? DEBOUNCE_MS : Math.min(retryDelay * 2, RETRY_MAX_MS);
489
- return retryDelay;
490
- }
491
-
492
- // A pane can be permanently unpushable, and in the field that is the COMMON
493
- // failure — not the rare one. Measured over 11,507 receipts on one fleet: 1,004
494
- // pushes failed, and 1,001 of them were a single agent whose TUI never matched
495
- // PROMPT_PATTERN. Excluding it, the rest of the fleet failed 3 times in ~10,500.
496
- // The distribution is bimodal: near-zero where the pattern matches, TOTAL where
497
- // it does not. So "retry until it works" must not be the only behaviour — for a
498
- // blackout pane it would grow `pending` without bound and re-paste the whole
499
- // accumulation on every cycle.
500
- //
501
- // Instead: after GIVEUP_AFTER consecutive failed flushes, SUSPEND pushing and
502
- // rewind in-memory state to the on-disk cursor. Nothing is lost, because the
503
- // cursor was never advanced — the messages sit in the inbox file with the cursor
504
- // still pointing before them, so read_messages serves them and the agent gets
505
- // its traffic by polling. That is not a fallback invented here; it is what the
506
- // blackout agent above was already doing.
507
- const GIVEUP_AFTER = parseInt(process.env.AGENT_COORD_GIVEUP_AFTER || "5", 10);
508
- let consecutiveFailures = 0;
509
- let pushSuspended = false;
510
- let suspendedAt = 0;
511
-
512
- // Rewind to the on-disk cursor: forget what we staged, collected and queued.
513
- // stagedOffsets MUST be cleared — it is the only thing preventing re-collection,
514
- // and while suspended the on-disk cursor is the single source of truth for what
515
- // this agent has yet to see.
516
- function suspendPush(reason) {
517
- pushSuspended = true;
518
- suspendedAt = Date.now();
519
- pending = [];
520
- pendingCommits = [];
521
- tierQueue.drain();
522
- for (const k of Object.keys(stagedOffsets)) delete stagedOffsets[k];
523
- process.stderr.write(
524
- `[tmux-pusher] SUSPENDING push to '${TMUX_TARGET}' after ${consecutiveFailures} consecutive failures (${reason}). ` +
525
- `The cursor was NOT advanced, so nothing is lost: this agent's traffic is still readable via read_messages. ` +
526
- `Retrying in ${Math.round(RETRY_MAX_MS / 1000)}s. If this persists, the pane's TUI probably does not match ` +
527
- `AGENT_COORD_PROMPT_PATTERN — fix that rather than waiting.\n`,
528
- );
529
- }
530
-
531
- function resumePush() {
532
- pushSuspended = false;
533
- consecutiveFailures = 0;
534
- retryDelay = 0;
535
- process.stderr.write(`[tmux-pusher] resuming push attempts to '${TMUX_TARGET}'\n`);
536
- }
537
-
538
- async function flush() {
539
- debounceTimer = null;
540
- if (sending) {
541
- scheduleFlush();
542
- return;
543
- }
544
- if (pending.length === 0) return;
545
- const batch = pending;
546
- pending = [];
547
- const commits = pendingCommits;
548
- pendingCommits = [];
549
- sending = true;
550
- try {
551
- // injectViaTmux reports what it could NOT put in the pane. Anything it
552
- // hands back was never delivered, so its offsets must not be committed —
553
- // the historical bug was committing the whole batch whenever inject
554
- // returned without throwing, which silently dropped every message refused
555
- // by the pane guard or left unsubmitted by pasteAndSubmit ({submitted:
556
- // false} RESOLVES, it does not throw). Delivered messages are dropped from
557
- // the retry set, so a partially-pasted batch neither loses its tail nor
558
- // re-pastes its head.
559
- const undelivered = await injectViaTmux(batch);
560
- if (undelivered.length === 0) {
561
- // Whole batch confirmed — only now advance the shared on-disk cursor
562
- // past everything it (including its coalesced routine) covers. Dying
563
- // before this line means redelivery on restart, never loss.
564
- commitOffsets(commits);
565
- retryDelay = 0;
566
- consecutiveFailures = 0;
567
- return;
568
- }
569
- // Partial or total refusal: hold the cursor where it is and retry only the
570
- // part that never landed. Offsets stay pending, so a crash here rewinds to
571
- // the last confirmed delivery rather than skipping the gap.
572
- consecutiveFailures++;
573
- process.stderr.write(
574
- `[tmux-pusher] ${undelivered.length}/${batch.length} message(s) not delivered — cursor held, will retry\n`,
575
- );
576
- pending = [...undelivered, ...pending];
577
- pendingCommits = [...commits, ...pendingCommits];
578
- if (consecutiveFailures >= GIVEUP_AFTER) {
579
- suspendPush("pane is not accepting pastes");
580
- return;
581
- }
582
- scheduleFlush(nextRetryDelay());
583
- } catch (e) {
584
- process.stderr.write(`[tmux-pusher] inject failed: ${e?.message ?? e}\n`);
585
- pending = [...batch, ...pending];
586
- pendingCommits = [...commits, ...pendingCommits];
587
- scheduleFlush(nextRetryDelay());
588
- } finally {
589
- sending = false;
590
- }
591
- }
592
-
593
- // formatBatch lives in tier.mjs (pure, unit-tested): urgent verbatim under
594
- // the banner, then at most ONE coalesced digest block of queued routine.
595
-
596
- // Inject a batch, preserving order. Runs of ordinary peer messages go in as one
597
- // banner-wrapped paste; each control command (/clear, /compact) is injected on
598
- // its own as a RAW line so the TUI runs it as a slash command — a banner or
599
- // `[DM …]` prefix would turn it into plain chat text instead.
600
- // Returns the messages it could NOT deliver, in batch order — empty array when
601
- // the whole batch landed. NEVER returns normally on a refusal it has swallowed:
602
- // every path that declines to paste, or pastes without submitting, must surface
603
- // here, because the caller commits the shared cursor on an empty return.
604
- async function injectViaTmux(batch) {
605
- // Fail-closed pane guard: never type into a pane that has dropped to a shell
606
- // (crashed/exited agent CLI). Bracketed paste protects a compliant TUI, but a
607
- // raw shell would execute pasted lines — so refuse, and report the whole
608
- // batch undelivered so the cursor stays put and it redelivers once the agent
609
- // CLI is back. (Reporting nothing here is what made "will redeliver" a lie.)
610
- const cmd = paneCurrentCommand();
611
- if (cmd !== null && SHELL_COMMANDS.has(cmd)) {
612
- process.stderr.write(
613
- `[tmux-pusher] pane '${TMUX_TARGET}' is at a shell ('${cmd}'), not the agent CLI — skipping inject (will redeliver)\n`,
614
- );
615
- return [...batch];
616
- }
617
- // Phase 5.3 Task 21.2 — STAMP THE REPLAY FACT BEFORE PASTING.
618
- //
619
- // Read once per inject, not per message: the receipt log is append-only and a
620
- // single read is a consistent snapshot for the whole batch.
621
- //
622
- // WHY THIS FILE IS THE RIGHT PLACE, and why it can work at all: receipts are
623
- // stamped AFTER a payload is typed (writeReceipts, below) and the push cursor
624
- // advances only after the whole batch lands (commitOffsets, in flush). So a
625
- // death anywhere in that window redelivers a message whose receipt is ALREADY
626
- // on disk — the evidence exists before the duplicate can, without anyone
627
- // having recorded anything for this purpose or remembered anything at all.
628
- //
629
- // Best-effort: an unreadable receipt log yields an empty map and therefore no
630
- // markers, which is the honest direction. A missing marker means "no earlier
631
- // delivery is RECORDED", never "this is provably the first" — see replay.mjs.
632
- let prior = new Map();
633
- try {
634
- prior = priorDeliveries(readFileSync(RECEIPTS_FILE, "utf8"));
635
- } catch {
636
- // no receipts yet, or unreadable — every message renders as a first delivery
637
- }
638
- batch = batch.map((m) => {
639
- const replay = replayInfo(prior, m?.id);
640
- return replay ? { ...m, replay } : m;
641
- });
642
-
643
- const undelivered = [];
644
- let run = [];
645
- const flushRun = async () => {
646
- if (run.length === 0) return;
647
- const inRun = run;
648
- run = [];
649
- let outcome;
650
- try {
651
- // bracketed=true → submit.mjs verifies by default (paste-chip land + Enter retry).
652
- outcome = await pasteAndSubmit(formatBatch(inRun, AGENT_ID, joinedRooms()), true);
653
- } catch (e) {
654
- // A throw here (nonzero paste-buffer/send-keys) must not abort the whole
655
- // batch: runs already pasted ARE delivered, and propagating would make
656
- // the caller re-queue them and paste the head twice — the duplicate-DM
657
- // symptom. Fail just this run.
658
- process.stderr.write(`[tmux-pusher] peer paste failed: ${e?.message ?? e}\n`);
659
- undelivered.push(...inRun);
660
- return;
661
- }
662
- if (outcome && outcome.submitted === false) {
663
- // pasteAndSubmit RESOLVES {submitted:false} for: paste never seen in the
664
- // input within PASTE_VERIFY_MS, input line unreadable / no PROMPT_PATTERN
665
- // match, or still-in-input after ENTER_RETRIES. All three are delivery
666
- // failures, not warnings.
667
- process.stderr.write(`[tmux-pusher] peer paste NOT submitted: ${outcome.reason}\n`);
668
- undelivered.push(...inRun);
669
- }
670
- writeReceipts(inRun, outcome); // stamp only AFTER the paste+submit resolves
671
- };
672
- for (const m of batch) {
673
- if (isControl(m)) {
674
- await flushRun();
675
- // Control commands are VERIFIED: capture-pane confirms the command
676
- // actually left the input. A receipt that says "typed" was being read
677
- // as "ran" — see writeReceipts.
678
- let outcome;
679
- try {
680
- outcome = await submitControlCommand(m.text.trim());
681
- } catch (e) {
682
- process.stderr.write(`[tmux-pusher] control command failed: ${e?.message ?? e}\n`);
683
- undelivered.push(m);
684
- continue;
685
- }
686
- if (outcome && outcome.submitted === false) undelivered.push(m);
687
- writeReceipts([m], outcome);
688
- } else {
689
- run.push(m);
690
- }
691
- }
692
- await flushRun();
693
- return undelivered;
694
- }
695
-
696
- // Append one delivery receipt per message AFTER it has been typed into the
697
- // pane. Receipts are out-of-band proof for the sender (it polls this file),
698
- // never injected into any agent's context — so verification costs zero tokens.
699
- // Best-effort: a failed receipt write must not break delivery, so we swallow.
700
- // `outcome` (control commands only) carries what verification actually saw.
701
- // WITHOUT it a receipt proved the pusher TYPED the command, and send_command
702
- // reported that as delivery:"confirmed" — proof of typing sold as proof of
703
- // execution. `submitted` is the honest field: false means it is still sitting
704
- // in the input, and the sender is told so instead of being reassured.
705
- function writeReceipts(msgs, outcome) {
706
- const lines = [];
707
- for (const m of msgs) {
708
- if (!m || !m.id) continue;
709
- lines.push(
710
- JSON.stringify({
711
- id: m.id,
712
- agentId: AGENT_ID,
713
- ts: Date.now(),
714
- from: m.from,
715
- control: m.control === true,
716
- // Build identity of the pusher that did the typing/verifying — the
717
- // same module-graph stamp the transport marker carries (SCRIPT_MTIME
718
- // covers hooks/*.mjs, not just this file). Lets deliveryOutcome note
719
- // a "confirmed" issued by pre-upgrade verification logic. Omitted
720
- // when unknown: absence reads as UNKNOWN downstream, never as fresh.
721
- ...(SCRIPT_MTIME !== undefined ? { scriptMtime: SCRIPT_MTIME } : {}),
722
- ...(outcome
723
- ? {
724
- submitted: outcome.submitted === true,
725
- verified: outcome.verified === true,
726
- ...(outcome.reason ? { reason: outcome.reason } : {}),
727
- }
728
- : {}),
729
- }),
730
- );
731
- }
732
- if (lines.length === 0) return;
733
- try {
734
- appendFileSync(RECEIPTS_FILE, lines.join("\n") + "\n");
735
- } catch (e) {
736
- process.stderr.write(`[tmux-pusher] receipt write failed: ${e?.message ?? e}\n`);
737
- }
738
- }
739
-
740
- // bracketed=true wraps the paste in bracketed-paste markers (paste-buffer -p) so
741
- // a compliant TUI treats the payload as inert data — embedded newlines can't
742
- // submit lines or smuggle a "/command". Control commands (/clear, /compact) must
743
- // paste RAW (bracketed=false) so the TUI still runs them as slash commands.
744
- //
745
- // The pipeline itself lives in ./submit.mjs, shared with scripts/coord-pusher.mjs
746
- // so the two cannot drift again. This wrapper only supplies tmux.
747
- const tmuxDeps = {
748
- target: TMUX_TARGET,
749
- buffer: BUFFER_NAME,
750
- run: (args) => spawnSync("tmux", args, { encoding: "utf8" }),
751
- runStdin: (args, payload) =>
752
- new Promise((resolve, reject) => {
753
- const load = spawn("tmux", args);
754
- load.on("error", reject);
755
- load.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`tmux ${args[0]} exit ${code}`))));
756
- load.stdin.end(payload);
757
- }),
758
- };
759
-
760
- function pasteAndSubmit(payload, bracketed = false) {
761
- return sharedPasteAndSubmit(tmuxDeps, payload, { bracketed });
762
- }
763
-
764
- // Control commands go through the preflight + verify path, never the plain one.
765
- function submitControlCommand(payload) {
766
- return sharedSubmitControl(tmuxDeps, payload);
767
- }
768
-
769
- // Publish transport marker so list_agents can show this agent is push-capable.
770
- writeTransportMarker();
771
- {
772
- const guardLine = readyProfileStartupLine("tmux-pusher");
773
- if (guardLine) process.stderr.write(guardLine);
774
- }
775
- let markerCleaned = false;
776
- const cleanupMarker = () => {
777
- if (markerCleaned) return;
778
- markerCleaned = true;
779
- try {
780
- unlinkSync(TRANSPORT_FILE);
781
- } catch {
782
- // already gone
783
- }
784
- };
785
- // Log BEFORE cleaning up. A silent exit that also removes its own marker is
786
- // indistinguishable from "never attached" — on 2026-07-28 all four pushers on
787
- // the bus vanished at once and the cause could not be determined afterwards,
788
- // because every other exit path logs and this one did not. Ruled out at the
789
- // time: dead-pane self-exit (logs), die() (logs), SIGKILL/OOM (the marker
790
- // would have survived), a normal exit (the poll intervals are not unref'd),
791
- // doctor's reaper (has-session returned 0 for every live pane), tests (all
792
- // isolate AGENT_COORD_DIR), coord-chat (not running), stop-agent.sh (would
793
- // have killed the session too) and a parent process-group signal (the pusher
794
- // has its own PGID and SID). That left a signal from outside the repo, with
795
- // nothing recorded to identify it.
796
- function logSignal(sig) {
797
- process.stderr.write(
798
- `[tmux-pusher] received ${sig} — cleaning up marker for '${AGENT_ID}' (pane ${TMUX_TARGET}, pid ${process.pid}) and exiting\n`,
799
- );
800
- }
801
-
802
- process.on("SIGINT", () => {
803
- logSignal("SIGINT");
804
- cleanupMarker();
805
- process.exit(0);
806
- });
807
- process.on("SIGTERM", () => {
808
- logSignal("SIGTERM");
809
- cleanupMarker();
810
- process.exit(0);
811
- });
812
- process.on("exit", cleanupMarker);
813
-
814
- function writeTransportMarker() {
815
- // Merge over the existing marker, never rebuild from scratch: attach_agent
816
- // wrote it first and owns fields only the server can know (its
817
- // serverBuildMtime provenance stamp, and whatever comes next). A
818
- // from-scratch rewrite here once dropped scriptMtime and silently disabled
819
- // doctor's stale-pusher-script check — see hooks/marker.mjs.
820
- let existing;
821
- try {
822
- existing = JSON.parse(readFileSync(TRANSPORT_FILE, "utf8"));
823
- } catch {
824
- existing = undefined; // first writer, or unreadable — our own fields stand alone
825
- }
826
- const marker = mergeTransportMarker(existing, {
827
- agentId: AGENT_ID,
828
- transport: "tmux-push",
829
- pid: process.pid,
830
- tmuxTarget: TMUX_TARGET,
831
- since: Date.now(),
832
- // MUST be included: attach_agent stamps this too, but this write happens
833
- // afterwards and would otherwise clobber it — and doctor SKIPS markers
834
- // without it (pre-v0.8.2 shape), so dropping it silently disabled the
835
- // stale-pusher-script check for every local pusher. Our own stamp is the
836
- // more truthful value anyway: it reflects the code THIS process loaded.
837
- scriptMtime: SCRIPT_MTIME,
838
- });
839
- const tmp = TRANSPORT_FILE + ".tmp";
840
- writeFileSync(tmp, JSON.stringify(marker));
841
- renameSync(tmp, TRANSPORT_FILE);
842
- }
843
-
844
- // Initial drain in case messages accumulated before the daemon started.
845
- checkOnce();
846
-
847
- // Watch + poll fallback.
848
- try {
849
- if (existsSync(INBOX_FILE)) watch(INBOX_FILE, () => checkOnce());
850
- } catch {
851
- // file may not exist yet; polling covers it
852
- }
853
- if (INCLUDE_ROOM) {
854
- for (const chan of joinedRooms()) watchRoom(chan);
855
- }
856
- setInterval(checkOnce, POLL_MS);
857
- // Self-exit when our target pane disappears (agent closed its window or crashed
858
- // without unregister). Without this the daemon lingers with a live pid, so its
859
- // transport marker reads as "live" forever and list_agents keeps the ghost
860
- // agent online. cleanupMarker (also wired to process exit) drops the marker.
861
- setInterval(checkTargetAlive, POLL_MS);
862
-
863
- process.stderr.write(
864
- `[tmux-pusher] watching inbox for '${AGENT_ID}' -> tmux ${TMUX_TARGET} (room=${INCLUDE_ROOM ? "on" : "off"})\n`,
865
- );
866
-
867
- // Probe the target pane; after TARGET_GRACE consecutive misses, give up and
868
- // exit cleanly so the marker is removed and the agent stops looking attached.
869
- function checkTargetAlive() {
870
- // `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
871
- // DOES NOT — tmux exits 0 for any target, including a pane killed a moment
872
- // ago, so the probe had ZERO discriminating power and reported every dead
873
- // pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
874
- // tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
875
- // Positive control, both directions: bogus target -> has-session exit 1,
876
- // display-message exit 0; live pane -> both exit 0.
877
- const p = spawnSync("tmux", ["has-session", "-t", TMUX_TARGET]);
878
- if (p.status === 0) {
879
- targetMisses = 0;
880
- return;
881
- }
882
- targetMisses++;
883
- if (targetMisses >= TARGET_GRACE) {
884
- process.stderr.write(
885
- `[tmux-pusher] target '${TMUX_TARGET}' gone for ${targetMisses} probe(s) — cleaning up and exiting\n`,
886
- );
887
- cleanupMarker();
888
- process.exit(0);
889
- }
890
- }
891
-
892
- function die(msg) {
893
- process.stderr.write(`[tmux-pusher] ${msg}\n`);
894
- process.exit(1);
895
- }
896
-
897
- // ---------- channel helpers (mirror src/store.ts) ----------
898
-
899
- function normalizeRoom(name) {
900
- if (!name) return DEFAULT_ROOM;
901
- const n = String(name).trim().replace(/^#+/, "").toLowerCase().replace(/[^a-z0-9._-]/g, "");
902
- return n || DEFAULT_ROOM;
903
- }
904
-
905
- function roomFile(chan) {
906
- const c = normalizeRoom(chan);
907
- // c is already normalized to a filesystem-safe charset.
908
- return c === DEFAULT_ROOM ? path.join(ROOT, "room.jsonl") : path.join(ROOT, "rooms", `${c}.jsonl`);
909
- }
910
-
911
- function getRoomOffset(cursor, chan) {
912
- const c = normalizeRoom(chan);
913
- return c === DEFAULT_ROOM ? cursor.roomOffset ?? 0 : cursor.roomOffsets?.[c] ?? 0;
914
- }
915
-
916
- function setRoomOffset(cursor, chan, n) {
917
- const c = normalizeRoom(chan);
918
- if (c === DEFAULT_ROOM) cursor.roomOffset = n;
919
- else (cursor.roomOffsets ??= {})[c] = n;
920
- }
921
-
922
- // Channels this agent is a MEMBER of, per rooms.json. #general included only
923
- // if membership says so.
924
- //
925
- // It used to be seeded `new Set([DEFAULT_ROOM])` before the membership loop, so
926
- // every pusher tailed #general whether or not the agent had ever joined it —
927
- // membership was honoured for every channel except the one with the most
928
- // traffic. Observed directly: rooms.json read `general -> 0 members []` while
929
- // agents joined only to a project room were taking #general cross-project
930
- // broadcasts into their panes.
931
- //
932
- // Deliberate consequence of dropping the seed: no registry (or an unparseable
933
- // one) now yields NO channels rather than #general. Absence of a registry is
934
- // absence of evidence of membership, and the safe reading of "I cannot tell
935
- // whether this agent joined" is not "push to it" — that is the assumption that
936
- // produced the defect. Nothing is lost either way: offsets are untouched here,
937
- // so read_messages still serves whatever the pusher did not.
938
- //
939
- // Cursor safety: this function only decides what gets TAILED. Offsets advance
940
- // exclusively in collectRoomChannel, which runs only for the channels returned
941
- // here — so a channel this agent is not in has its offset left exactly where it
942
- // was, and joining later resumes from that point. Not stranded (read_messages
943
- // reads the same cursor), not skipped (nothing advanced it meanwhile), not
944
- // double-served (the offset is preserved, not reset).
945
- function joinedRooms() {
946
- let reg = {};
947
- try {
948
- const raw = readFileSync(path.join(ROOT, "rooms.json"), "utf8");
949
- if (raw.trim()) reg = JSON.parse(raw);
950
- } catch {
951
- // no registry yet → no memberships to honour
952
- }
953
- const out = new Set();
954
- for (const [chan, e] of Object.entries(reg)) {
955
- if (e && Array.isArray(e.members) && e.members.includes(AGENT_ID)) out.add(chan);
956
- }
957
- return [...out];
958
- }
959
-
960
- function watchRoom(chan) {
961
- const f = roomFile(chan);
962
- if (watchedRooms.has(f)) return;
963
- try {
964
- if (existsSync(f)) {
965
- watch(f, () => checkOnce());
966
- watchedRooms.add(f);
967
- }
968
- } catch {
969
- // polling covers it until the file exists
970
- }
971
- }