agent-coord-mcp 0.26.24 → 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 +169 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +12 -1
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +60 -53
- package/dist/server-spread.js.map +1 -1
- package/dist/server.js +53 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/herdr-delivery.js +86 -26
- package/dist/tools/herdr-delivery.js.map +1 -1
- package/dist/tools/herdr-tail.js +243 -0
- package/dist/tools/herdr-tail.js.map +1 -0
- package/dist/tools/jsonl-offsets.js +37 -0
- package/dist/tools/jsonl-offsets.js.map +1 -0
- package/dist/tools/messaging.js +18 -0
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +18 -2
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +56 -21
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/transport.js +17 -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 +134 -18
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +4 -3
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/tmux.js +5 -2
- package/dist/transports/tmux.js.map +1 -1
- package/dist/transports/types.js +18 -3
- package/dist/transports/types.js.map +1 -1
- package/hooks/control-bytes.mjs +69 -0
- package/hooks/submit.mjs +227 -9
- package/hooks/tier.mjs +7 -1
- package/package.json +4 -2
- package/scripts/check-global-mcp-fallback.mjs +85 -0
- package/scripts/coord-pusher.mjs +5 -1
- 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 +170 -5
- package/src/gated-head.ts +12 -1
- package/src/server-spread.ts +52 -4
- package/src/server.ts +49 -3
- package/src/tools/herdr-delivery.ts +92 -24
- package/src/tools/herdr-tail.ts +276 -0
- package/src/tools/jsonl-offsets.ts +29 -0
- package/src/tools/messaging.ts +20 -0
- package/src/tools/records.ts +18 -2
- package/src/tools/registry.ts +56 -20
- package/src/tools/transport.ts +17 -154
- package/src/transports/config.ts +25 -12
- package/src/transports/herdr.ts +193 -17
- package/src/transports/index.ts +4 -3
- package/src/transports/tmux.ts +5 -2
- package/src/transports/types.ts +24 -4
- package/hooks/tmux-pusher.mjs +0 -967
- package/scripts/spawn-agent.sh +0 -94
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ A HERDR SEAT WAS DEAF TO EVERY TMUX SEAT, AND THE REASON IS A SINGLETON.
|
|
3
|
+
*
|
|
4
|
+
* `deliverToHerdrSeats` runs at SEND time and returns early unless `activeTransport().kind` is
|
|
5
|
+
* HERDR. `activeTransport()` returns a module-level singleton wired once at startup from the
|
|
6
|
+
* SENDER's own config — so that early return can only ever see the SENDER's transport, and a tmux
|
|
7
|
+
* seat's server could never type into a herdr pane. Measured both ways: `herdr → herdr` typed and
|
|
8
|
+
* woke the seat; `tmux → herdr` reached the inbox file and was never typed.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ THE EASIER FIX IS THE WRONG ONE: letting the SENDER drive herdr per recipient passes on a
|
|
11
|
+
* one-machine fleet and fails silently and asymmetrically on two, because every sender would need
|
|
12
|
+
* the `herdr` binary on its own host. So the duty goes to the seat that needs waking — the only
|
|
13
|
+
* party guaranteed to be where it is.
|
|
14
|
+
*
|
|
15
|
+
* ⛔⛆ AND THE FIRST VERSION OF THIS FILE WAS INERT ON EVERY SEAT, INCLUDING ITS AUTHOR'S.
|
|
16
|
+
* `8363968` started the tail only when `AGENT_COORD_BOUND_AGENT` was set at startup. No seat on
|
|
17
|
+
* this fleet sets it — identity binds at `join` — so the coordinator measured "herdr tail NOT
|
|
18
|
+
* started" on the PR's own server and qa read the variable unset on all six live seat servers.
|
|
19
|
+
* Its eleven tests injected the agent id and so could not see it. The tail now starts from the
|
|
20
|
+
* IDENTITY BINDING (`startTailOnBind`), idempotently, one per agent per process.
|
|
21
|
+
*
|
|
22
|
+
* ⛔ AND IT MUST NOT RUN THE REAPER. That version called `loadLiveTransports()` on every tick —
|
|
23
|
+
* the loader that DELETES markers it judges dead, named an hour earlier as the vanishing-marker
|
|
24
|
+
* mechanism. A reaper in a once-a-second loop on every seat is not shippable on the argument that
|
|
25
|
+
* the current build usually keeps the marker. This file reads its own seat's marker FILE directly,
|
|
26
|
+
* read-only, and never asks about anyone else's.
|
|
27
|
+
*
|
|
28
|
+
* THE CURSOR RULE IS THE PUSHER'S (⟨q-7be94b5e⟩): advance the PUSH cursor only past what was
|
|
29
|
+
* VERIFIABLY typed. Two corrections from qa's read of `8363968`, both now driven by tests:
|
|
30
|
+
* · the cursor advances to the byte AFTER THE DELIVERED LINE, never to the file's size — sizing
|
|
31
|
+
* it skipped a later message in the same tick whose paste then failed;
|
|
32
|
+
* · a cursor write that FAILS is held, and an in-process high-water mark stops the same message
|
|
33
|
+
* being re-typed on the next tick — the directory fix closed one cause of a paste loop, this
|
|
34
|
+
* closes the class.
|
|
35
|
+
*
|
|
36
|
+
* IDLE COST, COUNTED BY TESTS RATHER THAN ASSERTED HERE: when no tailed file has grown, a tick is
|
|
37
|
+
* one `stat()` per tailed file and nothing else. The marker and room membership are re-read every
|
|
38
|
+
* `REFRESH_TICKS` ticks, and no herdr subprocess runs until there is something to type.
|
|
39
|
+
*/
|
|
40
|
+
import { readFileSync, statSync } from "node:fs";
|
|
41
|
+
import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
|
|
42
|
+
import { activeTransport, HERDR, type Transport, type TransportMarker } from "../transports/index.js";
|
|
43
|
+
import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
|
|
44
|
+
import { newMessagesIn } from "./jsonl-offsets.js";
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_POLL_MS = 1000;
|
|
47
|
+
/** Marker and room membership are re-read this often. A room joined mid-session is picked up within this many ticks. */
|
|
48
|
+
export const REFRESH_TICKS = 30;
|
|
49
|
+
|
|
50
|
+
type Source = { kind: "dm" | "room"; chan?: string; file: string };
|
|
51
|
+
export type TailOutcome = {
|
|
52
|
+
delivered: { kind: "dm" | "room"; chan?: string; id: string }[];
|
|
53
|
+
held: { kind: "dm" | "room"; chan?: string; id: string; why: string }[];
|
|
54
|
+
idle?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Per-agent memory that survives between ticks — what makes the idle path cheap and the paste loop impossible. */
|
|
58
|
+
export type TailContext = {
|
|
59
|
+
tick: number;
|
|
60
|
+
marker: TransportMarker | null;
|
|
61
|
+
rooms: string[];
|
|
62
|
+
/** In-process high-water mark per source: never re-type below this, even if the persistent cursor could not be written. */
|
|
63
|
+
hw: Map<string, number>;
|
|
64
|
+
/** Last seen size per source: a source that has not grown costs one stat. */
|
|
65
|
+
sizes: Map<string, number>;
|
|
66
|
+
/**
|
|
67
|
+
* ⛔ START AT EOF, NEVER AT WHATEVER THE CURSOR FILE HAPPENS TO SAY. A tail is a LIVE push
|
|
68
|
+
* mechanism, not a mail reader: nothing that predates its start belongs on the pane. Measured
|
|
69
|
+
* 2026-09-17 — a daemon restart reset every push cursor to ~0, so a tail starting from disk
|
|
70
|
+
* state would have typed a 3.4MB inbox into one seat's pane. `read_messages` still serves that
|
|
71
|
+
* history on demand; that is the verb for it.
|
|
72
|
+
*/
|
|
73
|
+
seedEof: boolean;
|
|
74
|
+
/** Sources already seeded — a room discovered on a later refresh is seeded on ITS first sight, not skipped. */
|
|
75
|
+
seeded: Set<string>;
|
|
76
|
+
};
|
|
77
|
+
export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map(), seedEof, seeded: new Set() });
|
|
78
|
+
|
|
79
|
+
const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
|
|
80
|
+
const sizeOf = (file: string): number => {
|
|
81
|
+
try { return statSync(file).size; } catch { return 0; }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Read THIS seat's own marker, read-only. Never the reaping loader. */
|
|
85
|
+
export function readOwnMarker(agentId: string): TransportMarker | null {
|
|
86
|
+
try { return JSON.parse(readFileSync(transportFile(agentId), "utf8")) as TransportMarker; } catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The shared line reader — re-exported so callers of this module keep one import. */
|
|
90
|
+
export { newMessagesIn } from "./jsonl-offsets.js";
|
|
91
|
+
|
|
92
|
+
async function offsetOf(agentId: string, src: Source, ctx: TailContext): Promise<number> {
|
|
93
|
+
const c = await readPushCursorFor(agentId);
|
|
94
|
+
const persisted = src.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[src.chan!] ?? 0);
|
|
95
|
+
return Math.max(persisted, ctx.hw.get(keyOf(src)) ?? 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One pass. Everything that talks to the outside world is injectable, and nothing here schedules itself. */
|
|
99
|
+
export async function tailOnce(
|
|
100
|
+
agentId: string,
|
|
101
|
+
opts: {
|
|
102
|
+
transport?: Transport;
|
|
103
|
+
ctx?: TailContext;
|
|
104
|
+
markerOf?: (agentId: string) => TransportMarker | null;
|
|
105
|
+
roomsOf?: () => Promise<Record<string, { members?: string[] }>>;
|
|
106
|
+
} = {},
|
|
107
|
+
): Promise<TailOutcome> {
|
|
108
|
+
const out: TailOutcome = { delivered: [], held: [] };
|
|
109
|
+
const t = opts.transport ?? activeTransport();
|
|
110
|
+
if (!t || t.kind !== HERDR) return { ...out, idle: "this server's transport is not herdr — the tail is for a herdr seat's own inbox" };
|
|
111
|
+
const ctx = opts.ctx ?? newContext();
|
|
112
|
+
const refresh = ctx.tick % REFRESH_TICKS === 0;
|
|
113
|
+
ctx.tick += 1;
|
|
114
|
+
|
|
115
|
+
// A seat with NO marker re-reads it only on the refresh cadence too. The coordinator's gate found
|
|
116
|
+
// `|| !ctx.marker` here, which made a markerless seat — the exact state a vanished marker leaves —
|
|
117
|
+
// read its marker file on every tick while the stated idle cost said otherwise.
|
|
118
|
+
if (refresh) {
|
|
119
|
+
ctx.marker = (opts.markerOf ?? readOwnMarker)(agentId);
|
|
120
|
+
if (ctx.marker && ctx.marker.transport === HERDR && ctx.marker.rooms !== false) {
|
|
121
|
+
try {
|
|
122
|
+
const all = await (opts.roomsOf ?? getRooms)();
|
|
123
|
+
ctx.rooms = Object.entries(all ?? {}).filter(([, r]) => (r?.members ?? []).includes(agentId)).map(([chan]) => chan);
|
|
124
|
+
} catch { ctx.rooms = []; }
|
|
125
|
+
} else {
|
|
126
|
+
ctx.rooms = [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const marker = ctx.marker;
|
|
130
|
+
if (!marker || marker.transport !== HERDR) return { ...out, idle: `${agentId} has no herdr marker on this bus — nothing to type into (re-checked every ${REFRESH_TICKS} ticks)` };
|
|
131
|
+
|
|
132
|
+
const sources: Source[] = [{ kind: "dm", file: inboxFile(agentId) }, ...ctx.rooms.map((chan) => ({ kind: "room" as const, chan, file: roomFile(chan) }))];
|
|
133
|
+
|
|
134
|
+
for (const src of sources) {
|
|
135
|
+
const size = sizeOf(src.file);
|
|
136
|
+
const key = keyOf(src);
|
|
137
|
+
// ⭐ THE IDLE PATH: a source that has not grown since the last tick costs this one stat.
|
|
138
|
+
if (ctx.sizes.get(key) === size) continue;
|
|
139
|
+
ctx.sizes.set(key, size);
|
|
140
|
+
// ⛔ FIRST SIGHT OF A SOURCE UNDER seedEof: adopt EOF and type NOTHING. This is the only place
|
|
141
|
+
// the starting offset is decided, and it is decided EXPLICITLY rather than inherited from a
|
|
142
|
+
// cursor file that a restart may have reset. Per source, so a room joined later seeds on its
|
|
143
|
+
// own first sight instead of replaying its history.
|
|
144
|
+
if (ctx.seedEof && !ctx.seeded.has(key)) {
|
|
145
|
+
ctx.seeded.add(key);
|
|
146
|
+
ctx.hw.set(key, size);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const from = await offsetOf(agentId, src, ctx);
|
|
150
|
+
if (size <= from) continue;
|
|
151
|
+
|
|
152
|
+
for (const { msg, end } of newMessagesIn(src.file, from)) {
|
|
153
|
+
if (msg.from === agentId) { ctx.hw.set(key, end); continue; }
|
|
154
|
+
const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
|
|
155
|
+
const rendered = await renderForPane(msg as unknown as Message, agentId, where);
|
|
156
|
+
let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
|
|
157
|
+
try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
|
|
158
|
+
if (!r.delivered && r.safeToRetry === true) {
|
|
159
|
+
// HELD: the transport sent NO key (no ready input box, a draft, a dialog, an unreadable pane).
|
|
160
|
+
// Nothing moves and the message is retried next tick, which costs only a screen read. Stop
|
|
161
|
+
// this source so no later message is typed past one that did not land.
|
|
162
|
+
out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: r.error ?? "held — nothing was typed" });
|
|
163
|
+
ctx.sizes.delete(key); // force a re-read next tick
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
// ⛔ ANYTHING SHORT OF A VERIFIED DELIVERY DOES NOT MOVE THE PERSISTENT CURSOR (⟨q-15d763dc⟩).
|
|
167
|
+
// Keys may have reached the pane (text typed, an Enter pressed) without the screen proving the
|
|
168
|
+
// message was submitted, so it stays owed on disk: read_messages still serves it and a restart
|
|
169
|
+
// re-offers it. The in-process high-water mark alone stops it being re-typed every tick, since
|
|
170
|
+
// re-typing a message that may already be sitting in the box is the paste loop in another coat.
|
|
171
|
+
// A throw lands here too: nothing says whether a key went out before it.
|
|
172
|
+
if (!r.delivered || r.verified !== true) {
|
|
173
|
+
ctx.hw.set(key, end);
|
|
174
|
+
out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: `${r.outcome ?? (r.delivered ? "unverified" : "not delivered")} — ${r.error ?? "not verified"}; the persistent cursor was not moved (q-15d763dc)` });
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
// It landed and was verified. Record that in-process FIRST, so a cursor write that fails
|
|
178
|
+
// cannot turn one delivery into a paste loop, then persist exactly past this line.
|
|
179
|
+
ctx.hw.set(key, end);
|
|
180
|
+
const persisted = await advancePushCursor(agentId, where, end);
|
|
181
|
+
out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
|
|
182
|
+
if (!persisted) {
|
|
183
|
+
out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: "delivered, but the push cursor could not be written — held in memory so it is not re-typed; a restart before the next successful write may type it once more" });
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── the per-process registry of running tails ─────────────────────────────
|
|
192
|
+
|
|
193
|
+
type TailRecord = {
|
|
194
|
+
agentId: string;
|
|
195
|
+
startedAt: string;
|
|
196
|
+
ticks: number;
|
|
197
|
+
delivered: number;
|
|
198
|
+
held: number;
|
|
199
|
+
lastTickAt: string | null;
|
|
200
|
+
lastWhy: string | null;
|
|
201
|
+
stop: () => void;
|
|
202
|
+
};
|
|
203
|
+
const running = new Map<string, TailRecord>();
|
|
204
|
+
|
|
205
|
+
/** What `capabilities` reports: the tails THIS PROCESS is actually running, not the ones it could run. */
|
|
206
|
+
export function herdrTailState(): { agentId: string; running: true; startedAt: string; ticks: number; delivered: number; held: number; lastTickAt: string | null; lastWhy: string | null }[] {
|
|
207
|
+
return [...running.values()].map(({ stop: _stop, ...r }) => ({ ...r, running: true as const }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function stopHerdrTail(agentId: string): boolean {
|
|
211
|
+
const r = running.get(agentId);
|
|
212
|
+
if (!r) return false;
|
|
213
|
+
r.stop();
|
|
214
|
+
running.delete(agentId);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
export function stopAllHerdrTails(): void {
|
|
218
|
+
for (const id of [...running.keys()]) stopHerdrTail(id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Start the tail for `agentId` if this server's transport is herdr. IDEMPOTENT: a second bind of
|
|
223
|
+
* the same agent in the same process returns `running: true, started: false`.
|
|
224
|
+
*
|
|
225
|
+
* ⛔ NEVER OVERLAPS ITSELF: a tick still typing when the timer fires would interleave two pastes
|
|
226
|
+
* into one pane; the guard is one in-flight flag, because the next tick re-reads the cursor.
|
|
227
|
+
*/
|
|
228
|
+
export function ensureHerdrTail(
|
|
229
|
+
agentId: string,
|
|
230
|
+
opts: { transport?: Transport; pollMs?: number; seedEof?: boolean; tail?: (id: string, ctx: TailContext) => Promise<TailOutcome> } = {},
|
|
231
|
+
): { started: boolean; running: boolean; why: string } {
|
|
232
|
+
const t = opts.transport ?? activeTransport();
|
|
233
|
+
if (!t || t.kind !== HERDR) return { started: false, running: false, why: `transport is ${t?.kind ?? "unwired"}, not herdr — a tmux seat is woken by its pusher` };
|
|
234
|
+
if (running.has(agentId)) return { started: false, running: true, why: "already tailing this agent in this process" };
|
|
235
|
+
const ctx = newContext(opts.seedEof === true);
|
|
236
|
+
const run = opts.tail ?? ((id: string, c: TailContext) => tailOnce(id, { transport: t, ctx: c }));
|
|
237
|
+
const rec: TailRecord = { agentId, startedAt: new Date().toISOString(), ticks: 0, delivered: 0, held: 0, lastTickAt: null, lastWhy: null, stop: () => {} };
|
|
238
|
+
let inFlight = false;
|
|
239
|
+
const timer = setInterval(() => {
|
|
240
|
+
if (inFlight) return;
|
|
241
|
+
inFlight = true;
|
|
242
|
+
void run(agentId, ctx)
|
|
243
|
+
.then((o) => {
|
|
244
|
+
rec.ticks += 1;
|
|
245
|
+
rec.lastTickAt = new Date().toISOString();
|
|
246
|
+
rec.delivered += o.delivered.length;
|
|
247
|
+
rec.held += o.held.length;
|
|
248
|
+
rec.lastWhy = o.held.at(-1)?.why ?? o.idle ?? null;
|
|
249
|
+
})
|
|
250
|
+
.catch((e) => { rec.lastWhy = `tick threw: ${(e as Error).message}`; })
|
|
251
|
+
.finally(() => { inFlight = false; });
|
|
252
|
+
}, opts.pollMs ?? DEFAULT_POLL_MS);
|
|
253
|
+
timer.unref?.(); // optional-call: `unref` is absent on some host timer shims, and a server that cannot unref its tail must still start it
|
|
254
|
+
rec.stop = () => clearInterval(timer);
|
|
255
|
+
running.set(agentId, rec);
|
|
256
|
+
return { started: true, running: true, why: `tailing ${agentId}'s inbox and rooms every ${opts.pollMs ?? DEFAULT_POLL_MS}ms` };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The hook the server calls at EVERY identity binding — `join`, a gated first claim, the env var,
|
|
261
|
+
* or (HTTP) an authenticated request from a PRE-BOUND identity.
|
|
262
|
+
*
|
|
263
|
+
* ⛔ THE BINDING TRANSITION IS NOT REACHABLE UNDER HTTP, WHICH IS WHY THIS ALSO HANGS OFF AUTH.
|
|
264
|
+
* The `bound === undefined` sites below only fire when a session CLAIMS an id. A token-authenticated
|
|
265
|
+
* daemon resolves identity per request and reports `pre-bound (N agents)`, so `bound` is never
|
|
266
|
+
* undefined and NO tail ever started — measured 2026-09-17 as `herdrTail: []` on a freshly
|
|
267
|
+
* restarted daemon whose seats had re-joined after it. With the send-time path deferring owed
|
|
268
|
+
* messages to "the recipient's own tail", a tail that never exists makes that deferral a permanent
|
|
269
|
+
* silent drop: one message behind and the seat is deaf for good (⟨q-cdb5b007⟩).
|
|
270
|
+
*
|
|
271
|
+
* Idempotent, so the per-request call costs one Map lookup once the tail is up.
|
|
272
|
+
*/
|
|
273
|
+
export function startTailOnBind(agentId: string, log: (line: string) => void = (l) => console.error(l)): void {
|
|
274
|
+
const r = ensureHerdrTail(agentId, { seedEof: true });
|
|
275
|
+
if (r.started) log(`[agent-coord-mcp] herdr tail: ${r.why}`);
|
|
276
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages appended to a JSONL bus file after byte `from`, each with the absolute byte offset just
|
|
3
|
+
* past its own line. Shared by the send-time herdr path and the seat's own tail, so both agree on
|
|
4
|
+
* exactly which bytes a delivery covers — a second reader would be a second truth about where a
|
|
5
|
+
* seat's cursor may move.
|
|
6
|
+
*
|
|
7
|
+
* A partial trailing line is left for the next read rather than guessed at, and a line that is not
|
|
8
|
+
* JSON is skipped with the offset moving past it.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export type OffsetMessage = { msg: Record<string, unknown>; end: number };
|
|
13
|
+
|
|
14
|
+
export function newMessagesIn(file: string, from: number): OffsetMessage[] {
|
|
15
|
+
let buf: Buffer;
|
|
16
|
+
try { buf = readFileSync(file); } catch { return []; }
|
|
17
|
+
const out: OffsetMessage[] = [];
|
|
18
|
+
let pos = Math.min(Math.max(0, from), buf.length);
|
|
19
|
+
while (pos < buf.length) {
|
|
20
|
+
const nl = buf.indexOf(0x0a, pos);
|
|
21
|
+
if (nl === -1) break;
|
|
22
|
+
const line = buf.subarray(pos, nl).toString("utf8");
|
|
23
|
+
const end = nl + 1;
|
|
24
|
+
pos = end;
|
|
25
|
+
if (!line.trim()) continue;
|
|
26
|
+
try { out.push({ msg: JSON.parse(line), end }); } catch { /* not a message; the offset moves past it */ }
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
package/src/tools/messaging.ts
CHANGED
|
@@ -20,6 +20,9 @@ import { readLog } from "./logwatch.js";
|
|
|
20
20
|
// `dist/`, so this resolves the same in the built package as in source.
|
|
21
21
|
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
22
22
|
import { priorDeliveries, replayInfo } from "../../hooks/replay.mjs";
|
|
23
|
+
// ⟨q-e5cb3538⟩ The pane-unsafe byte class is single-sourced there too, shared with the renderer.
|
|
24
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
25
|
+
import { findControlByte } from "../../hooks/control-bytes.mjs";
|
|
23
26
|
import { renderRecord } from "./render.js";
|
|
24
27
|
import path from "node:path";
|
|
25
28
|
import {
|
|
@@ -344,6 +347,23 @@ export async function sendMessageTool(args: {
|
|
|
344
347
|
const scribeCheck = checkVerdictScribe(args.from, args.record);
|
|
345
348
|
if (!scribeCheck.ok) return { ok: false as const, error: scribeCheck.error };
|
|
346
349
|
|
|
350
|
+
// ⟨q-e5cb3538⟩ — INGRESS POLICY: REFUSE, don't rewrite. A control byte in content is a
|
|
351
|
+
// keystroke in every pane the message is typed into (CR submits, ESC interrupts, ETX is
|
|
352
|
+
// Ctrl-C). Refused rather than silently escaped because the stored message is what gets
|
|
353
|
+
// cited and replayed; a rewrite would make it differ from what the sender wrote, and the
|
|
354
|
+
// sender is the only party who can say what the byte was for. Line feed and tab pass.
|
|
355
|
+
// Render-time escaping (hooks/tier.mjs) still covers history stored before this check.
|
|
356
|
+
const control = findControlByte({ from: args.from, to: args.to, room: args.room, text: args.text, record: args.record, repo: args.repo });
|
|
357
|
+
if (control) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false as const,
|
|
360
|
+
error:
|
|
361
|
+
`refused: '${control.path}' carries a control character (${control.escape}) at offset ${control.offset}. ` +
|
|
362
|
+
"Delivered to a pane it would be typed as a keystroke in the recipient's session. " +
|
|
363
|
+
"Remove it, or write it out visibly (e.g. \\r); quoted external text (PR bodies, commit messages) is the usual carrier. Nothing was written.",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
347
367
|
// A `done` must cite the work it claims. Presence and shape only — resolving
|
|
348
368
|
// the ref against gh/git is a consumer's job, and the send path makes no
|
|
349
369
|
// network calls. Rejected as a value, not a throw, mirroring the
|
package/src/tools/records.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
|
|
|
45
45
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
46
46
|
import { haltState } from "./stall.js";
|
|
47
47
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
48
|
-
import { prRefsIn } from "./record-events.js";
|
|
48
|
+
import { prRefsIn, leadingItemIdOf } from "./record-events.js";
|
|
49
49
|
|
|
50
50
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
51
51
|
const DONE_DOC = "docs/DONE.md";
|
|
@@ -1199,7 +1199,23 @@ export async function landTool(
|
|
|
1199
1199
|
}
|
|
1200
1200
|
}
|
|
1201
1201
|
|
|
1202
|
-
|
|
1202
|
+
/*
|
|
1203
|
+
* ⛔ ⟨q-552b9912⟩ "ALREADY LOGGED" IS A QUESTION ABOUT THE ITEM, NOT THE PR.
|
|
1204
|
+
*
|
|
1205
|
+
* It asked whether ANY DONE entry cited this PR, so the second item a PR closed found the first item's entry,
|
|
1206
|
+
* reported `alreadyLogged: true`, and wrote nothing: the row left the queue and never reached the delivery record,
|
|
1207
|
+
* so `scan_record_events` emitted no item event for it. Measured twice on 2026-09-16 — ⟨q-3b8e05af⟩ on #360 and
|
|
1208
|
+
* ⟨q-144c97a8⟩ on #366, both hand-repaired (07338a1, 00c8315).
|
|
1209
|
+
*
|
|
1210
|
+
* Keyed per item BY POSITION ONLY: an entry is this item's when it LEADS with `⟨id⟩` (the form this verb writes). A
|
|
1211
|
+
* by-mention fallback was tried and REMOVED (qa's FAIL on #370 @ fce3ae1): an id-less entry for this PR that merely
|
|
1212
|
+
* MENTIONS the id — B never landed — read as logged and wrote nothing, the same defect narrowed. A mention is not a
|
|
1213
|
+
* record. A land with no item keeps the PR key, since there is no item to key on.
|
|
1214
|
+
*/
|
|
1215
|
+
const citesThisPr = (e: { ref?: string }) => new RegExp(`#${n}\\b`).test(String(e.ref ?? ""));
|
|
1216
|
+
const already = target
|
|
1217
|
+
? doneEntriesOf(d.doc).some((e) => leadingItemIdOf(e.text) === target.id)
|
|
1218
|
+
: doneEntriesOf(d.doc).some(citesThisPr);
|
|
1203
1219
|
|
|
1204
1220
|
// A DONE LINE A HUMAN WOULD NOT HAVE WRITTEN IS NOT A DONE LINE.
|
|
1205
1221
|
//
|
package/src/tools/registry.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, openSync, statSync, watch } from "node:fs";
|
|
|
6
6
|
import { promises as fsp } from "node:fs";
|
|
7
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { detectSpread, installedBuild } from "../server-spread.js";
|
|
9
|
+
import { detectSpread, installedBuild, pidRunning } from "../server-spread.js";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import {
|
|
@@ -161,6 +161,9 @@ export async function registerTool(args: {
|
|
|
161
161
|
registeredAt: existing?.registeredAt ?? now,
|
|
162
162
|
lastHeartbeat: now,
|
|
163
163
|
capabilities: existing?.capabilities,
|
|
164
|
+
// ⟨q-18a719c5⟩ The entry is rebuilt here, so the stamp is rebuilt with it — from the
|
|
165
|
+
// server answering THIS register, which is the one serving the seat now.
|
|
166
|
+
...answeringServerIdentity(),
|
|
164
167
|
// Omitted → carried forward untouched. Only an explicit `false` revokes.
|
|
165
168
|
...(args.proseOnly === undefined
|
|
166
169
|
? existing?.proseOnly
|
|
@@ -285,6 +288,39 @@ export async function quitTool(args: { agentId: string }): Promise<never> {
|
|
|
285
288
|
|
|
286
289
|
export const heartbeatSchema = { agentId: z.string().min(1) };
|
|
287
290
|
|
|
291
|
+
/**
|
|
292
|
+
* ⟨q-18a719c5⟩ THE ANSWERING SERVER'S IDENTITY — what `serverSpread` places a seat by. It exists
|
|
293
|
+
* only inside the process that serves the seat, so every path on which a session binds an
|
|
294
|
+
* identity stamps it: `register` (which `join` calls), the first-claim binding in server.ts
|
|
295
|
+
* (a server that restarts and re-binds through any gated tool, `attach_agent` included),
|
|
296
|
+
* `rename_agent` (the renaming session serves the new id), and `heartbeat`. Before this,
|
|
297
|
+
* only `heartbeat` stamped, no code path called it, and `register` rebuilt the entry and
|
|
298
|
+
* dropped whatever it had written.
|
|
299
|
+
*/
|
|
300
|
+
export function answeringServerIdentity(): { serverPid: number; serverStartedAt: number; serverModule?: string } {
|
|
301
|
+
return {
|
|
302
|
+
serverPid: process.pid,
|
|
303
|
+
// Derived from uptime rather than read from a file: an mtime tracks writes (a reinstall of
|
|
304
|
+
// identical bytes moves it) while uptime is a fact about THIS process.
|
|
305
|
+
serverStartedAt: Date.now() - Math.round(process.uptime() * 1000),
|
|
306
|
+
// WHAT this process is executing, not what it is labelled. Resolved from this module's own
|
|
307
|
+
// URL, so a server running a dev `dist/` says so instead of inheriting the installed path.
|
|
308
|
+
serverModule: installedBuild(import.meta.url, statSync)?.module,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Stamp an EXISTING registry entry with the answering server's identity. False when absent. */
|
|
313
|
+
export async function stampServerIdentity(agentId: string): Promise<boolean> {
|
|
314
|
+
let stamped = false;
|
|
315
|
+
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
316
|
+
if (!current[agentId]) return current;
|
|
317
|
+
Object.assign(current[agentId], answeringServerIdentity());
|
|
318
|
+
stamped = true;
|
|
319
|
+
return current;
|
|
320
|
+
});
|
|
321
|
+
return stamped;
|
|
322
|
+
}
|
|
323
|
+
|
|
288
324
|
export async function heartbeatTool(args: { agentId: string }) {
|
|
289
325
|
let missing = false;
|
|
290
326
|
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
@@ -293,23 +329,8 @@ export async function heartbeatTool(args: { agentId: string }) {
|
|
|
293
329
|
return current;
|
|
294
330
|
}
|
|
295
331
|
current[args.agentId].lastHeartbeat = Date.now();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* is answering, which is the only place these two facts exist: `capabilities`
|
|
299
|
-
* reports them solely to its own caller, and no seat can query another seat's
|
|
300
|
-
* server at all. Publishing them here is what makes a cross-seat comparison
|
|
301
|
-
* possible without every seat having to volunteer it in prose.
|
|
302
|
-
*
|
|
303
|
-
* `startedAt` is derived from uptime rather than read from a file: an mtime tracks
|
|
304
|
-
* writes (a reinstall of identical bytes moves it) while uptime is a fact about
|
|
305
|
-
* THIS process.
|
|
306
|
-
*/
|
|
307
|
-
current[args.agentId].serverPid = process.pid;
|
|
308
|
-
current[args.agentId].serverStartedAt = Date.now() - Math.round(process.uptime() * 1000);
|
|
309
|
-
// ⛔ WHAT this process is executing, not what it is labelled. Resolved from this
|
|
310
|
-
// module's own URL, so a server running a dev `dist/` says so instead of inheriting
|
|
311
|
-
// the installed path — the case that made a 3-build fleet read AGREED.
|
|
312
|
-
current[args.agentId].serverModule = installedBuild(import.meta.url, statSync)?.module;
|
|
332
|
+
// ⛔ STAMP THE ANSWERING PROCESS — `⟨q-cec42e20⟩`; see `answeringServerIdentity`.
|
|
333
|
+
Object.assign(current[args.agentId], answeringServerIdentity());
|
|
313
334
|
return current;
|
|
314
335
|
});
|
|
315
336
|
if (missing) return { ok: false, error: `agent '${args.agentId}' not registered` };
|
|
@@ -464,6 +485,8 @@ export async function listAgentsTool() {
|
|
|
464
485
|
serverModule: a.serverModule,
|
|
465
486
|
})),
|
|
466
487
|
installedBuild(import.meta.url, statSync),
|
|
488
|
+
// ⟨q-18a719c5⟩ a stamp whose server is gone reads unknown: ask the kernel, here, in production.
|
|
489
|
+
{ isRunning: pidRunning },
|
|
467
490
|
);
|
|
468
491
|
|
|
469
492
|
// ⟨q-178878aa⟩ — the humans the bus knows: visible here, read at send time, never evicted.
|
|
@@ -541,6 +564,18 @@ export function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: n
|
|
|
541
564
|
return isPidAlive(marker.pid);
|
|
542
565
|
}
|
|
543
566
|
|
|
567
|
+
/**
|
|
568
|
+
* ⟨q-abd88dd4⟩ Does this marker hold a LIVE LOCAL PROCESS — a pusher someone could signal, or
|
|
569
|
+
* wait on? A herdr marker never does: it has no pusher, and its `pid` is addressed to pre-herdr
|
|
570
|
+
* readers (pid 1, see `herdrMarkerPid`), so reading it as a process would make pid 1 look like a
|
|
571
|
+
* running pusher. Every pid-as-process decision about a marker goes through here; liveness of a
|
|
572
|
+
* herdr seat is `isMarkerLive`, which asks herdr for the pane.
|
|
573
|
+
*/
|
|
574
|
+
export function markerHoldsLiveProcess(marker: TransportMarker | null | undefined): boolean {
|
|
575
|
+
if (!marker || marker.transport === HERDR) return false;
|
|
576
|
+
return isPidAlive(marker.pid);
|
|
577
|
+
}
|
|
578
|
+
|
|
544
579
|
export function isPidAlive(pid: number): boolean {
|
|
545
580
|
if (!pid || pid <= 0) return false;
|
|
546
581
|
try {
|
|
@@ -682,7 +717,7 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
|
|
|
682
717
|
// must re-attach under the new id (join/attach_agent) to restore push.
|
|
683
718
|
const liveTransport = await readJson<TransportMarker | null>(transportFile(oldId), null);
|
|
684
719
|
let detachedTransport = false;
|
|
685
|
-
if (
|
|
720
|
+
if (markerHoldsLiveProcess(liveTransport)) {
|
|
686
721
|
await detachAgentTool({ agentId: oldId });
|
|
687
722
|
detachedTransport = true;
|
|
688
723
|
}
|
|
@@ -690,7 +725,8 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
|
|
|
690
725
|
// Registry: move the entry under the new key.
|
|
691
726
|
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
692
727
|
if (current[oldId]) {
|
|
693
|
-
|
|
728
|
+
// ⟨q-18a719c5⟩ The renaming session serves the new id, so the stamp is re-taken from it.
|
|
729
|
+
current[newId] = { ...current[oldId], agentId: newId, ...answeringServerIdentity() };
|
|
694
730
|
delete current[oldId];
|
|
695
731
|
}
|
|
696
732
|
return current;
|