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.
- package/README.md +31 -70
- package/dist/capabilities.js +41 -1
- 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 +16 -148
- 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/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 +42 -3
- package/src/server.ts +12 -3
- package/src/tools/herdr-tail.ts +37 -5
- package/src/tools/transport.ts +14 -152
- 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/hooks/tmux-pusher.mjs
DELETED
|
@@ -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
|
-
}
|