agent-coord-mcp 0.26.24 → 0.26.25

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.
Files changed (47) hide show
  1. package/dist/capabilities.js +129 -4
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/gated-head.js +12 -1
  4. package/dist/gated-head.js.map +1 -1
  5. package/dist/server-spread.js +60 -53
  6. package/dist/server-spread.js.map +1 -1
  7. package/dist/server.js +41 -2
  8. package/dist/server.js.map +1 -1
  9. package/dist/tools/herdr-delivery.js +86 -26
  10. package/dist/tools/herdr-delivery.js.map +1 -1
  11. package/dist/tools/herdr-tail.js +221 -0
  12. package/dist/tools/herdr-tail.js.map +1 -0
  13. package/dist/tools/jsonl-offsets.js +37 -0
  14. package/dist/tools/jsonl-offsets.js.map +1 -0
  15. package/dist/tools/messaging.js +18 -0
  16. package/dist/tools/messaging.js.map +1 -1
  17. package/dist/tools/records.js +18 -2
  18. package/dist/tools/records.js.map +1 -1
  19. package/dist/tools/registry.js +56 -21
  20. package/dist/tools/registry.js.map +1 -1
  21. package/dist/tools/transport.js +4 -3
  22. package/dist/tools/transport.js.map +1 -1
  23. package/dist/transports/herdr.js +118 -18
  24. package/dist/transports/herdr.js.map +1 -1
  25. package/dist/transports/index.js +1 -1
  26. package/dist/transports/index.js.map +1 -1
  27. package/dist/transports/types.js.map +1 -1
  28. package/hooks/control-bytes.mjs +69 -0
  29. package/hooks/submit.mjs +227 -9
  30. package/hooks/tier.mjs +7 -1
  31. package/hooks/tmux-pusher.mjs +5 -1
  32. package/package.json +1 -1
  33. package/scripts/coord-pusher.mjs +5 -1
  34. package/src/capabilities.ts +129 -3
  35. package/src/gated-head.ts +12 -1
  36. package/src/server-spread.ts +52 -4
  37. package/src/server.ts +38 -1
  38. package/src/tools/herdr-delivery.ts +92 -24
  39. package/src/tools/herdr-tail.ts +244 -0
  40. package/src/tools/jsonl-offsets.ts +29 -0
  41. package/src/tools/messaging.ts +20 -0
  42. package/src/tools/records.ts +18 -2
  43. package/src/tools/registry.ts +56 -20
  44. package/src/tools/transport.ts +5 -4
  45. package/src/transports/herdr.ts +175 -17
  46. package/src/transports/index.ts +1 -1
  47. package/src/transports/types.ts +5 -0
@@ -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
- const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
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
  //
@@ -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
- * ⛔ STAMP THE ANSWERING PROCESS — `⟨q-cec42e20⟩`. This runs INSIDE the server that
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 (liveTransport && isPidAlive(liveTransport.pid)) {
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
- current[newId] = { ...current[oldId], agentId: newId };
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;
@@ -1,4 +1,4 @@
1
- import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
1
+ import { loadLiveTransports, isMarkerLive, isPidAlive, markerHoldsLiveProcess } from "./registry.js";
2
2
  import {
3
3
  TMUX_PUSH,
4
4
  registerTmuxHost,
@@ -685,7 +685,8 @@ export async function attachAgentTool(args: {
685
685
  agentId: args.agentId,
686
686
  transport: HERDR,
687
687
  target: marker.target,
688
- pid: 0,
688
+ pid: marker.pid,
689
+ pidWhy: marker.pidWhy,
689
690
  rooms: marker.rooms,
690
691
  note: "herdr socket transport: no pusher process — delivery is made in-process by this server through herdr's socket API; liveness is herdr's own pane status",
691
692
  };
@@ -711,10 +712,10 @@ export async function attachAgentTool(args: {
711
712
 
712
713
  // If something's already attached, refuse rather than spawn a second pusher.
713
714
  const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
714
- if (existing && isPidAlive(existing.pid)) {
715
+ if (markerHoldsLiveProcess(existing)) {
715
716
  return {
716
717
  ok: false,
717
- error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
718
+ error: `agent '${args.agentId}' already has a live ${existing!.transport} attached (pid ${existing!.pid}). Call detach_agent first.`,
718
719
  existing,
719
720
  };
720
721
  }
@@ -36,6 +36,16 @@
36
36
  import { spawnSync } from "node:child_process";
37
37
  import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
38
38
  import { HERDR, TICK_READS_AS, TICK_STORED_AS, targetOf } from "./types.js";
39
+ // ⟨q-dc83023a⟩ The chip grammar and the pane-unsafe byte class are single-sourced in hooks/, shared
40
+ // with both pushers. `hooks/` ships beside `dist/`, so these resolve the same when installed.
41
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
42
+ import { PASTE_CHIP_RE, readReadyBox, readyProfile, refusalBeforeKeys, boxHoldsPayload, transcriptGained } from "../../hooks/submit.mjs";
43
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
44
+ import { neutralizeControls } from "../../hooks/control-bytes.mjs";
45
+
46
+ /** Bracketed-paste markers (xterm mode 2004), which Claude Code and tmux `paste-buffer -p` both speak. */
47
+ export const PASTE_START = "\x1b[200~";
48
+ export const PASTE_END = "\x1b[201~";
39
49
 
40
50
  export type HerdrError = { code: string; message: string };
41
51
  export type HerdrResult = {
@@ -118,6 +128,20 @@ function processInfoOf(r: HerdrResult): ProcessInfo | undefined {
118
128
  }
119
129
  const LIVE_STATUSES = new Set(["idle", "working", "blocked", "done"]);
120
130
 
131
+ /**
132
+ * ⟨q-15d763dc⟩ What a push did, so a caller can tell a message that is safe to try again from one
133
+ * that may already be sitting in the pane:
134
+ * delivered — verified: the ready box took it and the transcript shows it. The ONLY
135
+ * outcome a cursor may advance on.
136
+ * held — NO key was sent (no ready box, a draft, a menu, or the pane unreadable).
137
+ * Safe to retry.
138
+ * typed-unconfirmed — the text was typed but did not show in the box; Enter was NOT sent.
139
+ * pending — the text is still in the box after every Enter.
140
+ * unverified — Enter was sent and the screen after it does not prove submission.
141
+ */
142
+ export type PushOutcome = "delivered" | "held" | "typed-unconfirmed" | "pending" | "unverified";
143
+ export type HerdrPushResult = { delivered: boolean; verified?: boolean; outcome?: PushOutcome; safeToRetry?: boolean; error?: string; enters?: number; unguarded?: boolean; busy?: boolean };
144
+
121
145
  export type HerdrTransportOptions = {
122
146
  run?: HerdrRunner;
123
147
  /** Milliseconds to wait before reading a pane back after typing. */
@@ -126,14 +150,75 @@ export type HerdrTransportOptions = {
126
150
  sleep?: (ms: number) => void;
127
151
  /** Lines of pane to read back when verifying a delivery. */
128
152
  readLines?: number;
153
+ /** Injectable marker-pid decision (tests); defaults to asking the kernel about pid 1. */
154
+ markerPid?: () => HerdrMarkerPid;
155
+ /**
156
+ * The environment `attach` reads its default pane from. Injectable because it is AMBIENT
157
+ * PROCESS STATE, and ambient state is the one input an injected runner does not cover.
158
+ *
159
+ * ⛔ MEASURED 2026-09-16, the day seats moved onto herdr: the attach test injected a scripted
160
+ * runner and still read the REAL `process.env.HERDR_PANE_ID`. Outside herdr the variable was
161
+ * absent and the test passed; inside a herdr pane it resolved to the gater's own pane
162
+ * (`wA6:p1`), the scripted runner had never heard of it, and the suite went red on every
163
+ * herdr-hosted tree for a reason unrelated to any diff. Stripping `HERDR_PANE_ID` alone — one
164
+ * variable at a time, six others left in place — was what turned it green.
165
+ */
166
+ env?: Record<string, string | undefined>;
129
167
  };
130
168
 
169
+ /**
170
+ * ⟨q-abd88dd4⟩ — THE PID A HERDR MARKER CARRIES, chosen for the readers that CANNOT be patched.
171
+ *
172
+ * A herdr seat has no pusher, so its marker used to carry pid 0. Every server build from before
173
+ * the herdr transport (0.19.1, kit 0.26.19, published 0.26.22 — reproduced in a throwaway bus
174
+ * against each) decides marker liveness as `isPidAlive(marker.pid)` for everything but
175
+ * `tmux-push-remote`, and `isPidAlive(0)` is false, so ONE ordinary read (`list_agents`,
176
+ * `status`, `stall_check`, `send_command`) DELETES the marker. A mixed-build fleet deafens its
177
+ * herdr seats as a side effect of looking at the roster.
178
+ *
179
+ * pid 1 is kept by all three: it always exists, so `isPidAlive(1)` is true. Their signal paths,
180
+ * enumerated by qa across all three on its gate: kit 0.26.19 and published 0.26.22 gate
181
+ * `detach_agent`'s SIGTERM on `isPusherProcess(marker.pid)`, which reads the process COMMAND, so they
182
+ * never signal launchd whatever uid they run as. Only 0.19.1's detach is unguarded (`isPidAlive`
183
+ * then `process.kill(marker.pid, "SIGTERM")`, reachable from `detach_agent`, `unregister` and
184
+ * `rename_agent`), and it enforces identity, so its caller must be bound as that herdr seat. As a
185
+ * non-root process that kill gets EPERM — measured `killed:false`, launchd untouched. The seat's own
186
+ * server pid would have been kept by all three as well, and 0.19.1's unguarded detach would have
187
+ * SIGTERMed that seat's MCP server; that shape is rejected.
188
+ *
189
+ * ⛔ SO pid 1 IS WRITTEN ONLY WHEN THIS PROCESS COULD NOT SIGNAL IT, asked of the kernel with
190
+ * signal 0 rather than inferred from a uid: as root, or in a container where pid 1 is our own
191
+ * user's process (often the server itself), `kill(1, 0)` succeeds and an old reader running
192
+ * alongside could SIGTERM init. There the marker falls back to pid 0 and says why — an old
193
+ * reader then deletes it, which is deafness, and deafness is recoverable where a signal to init
194
+ * is not. RESIDUAL, stated as narrowly as it is: a 0.19.1-era server, running as ROOT, bound as
195
+ * the herdr seat's OWN identity, calling detach, unregister or rename, reaches init. Nothing a
196
+ * marker says can remove a privilege its reader holds.
197
+ *
198
+ * The CURRENT build never reads this pid as liveness: a herdr marker is live exactly when herdr
199
+ * says its pane exists (`isMarkerLive`), and nothing signals it (`detach_agent` removes the
200
+ * marker, `markerHoldsLiveProcess` answers false).
201
+ */
202
+ export type HerdrMarkerPid = { pid: 0 | 1; why: string };
203
+ export function herdrMarkerPid(kill: (pid: number, signal: 0) => unknown = (p, s) => process.kill(p, s)): HerdrMarkerPid {
204
+ try {
205
+ kill(1, 0);
206
+ return { pid: 0, why: "this process may signal pid 1 (root, or a container whose pid 1 is ours), so pid 1 is not safe to advertise; pre-herdr readers will reap this marker" };
207
+ } catch (e) {
208
+ const code = (e as NodeJS.ErrnoException).code;
209
+ if (code === "EPERM") return { pid: 1, why: "pid 1 exists and this process may not signal it, so pre-herdr readers keep the marker and their detach cannot signal it" };
210
+ return { pid: 0, why: `kill(1, 0) answered ${code ?? "an unexpected error"}, not EPERM — pid 1 not advertised` };
211
+ }
212
+ }
213
+
131
214
  export class HerdrTransport implements Transport {
132
215
  readonly kind: TransportKind = HERDR;
133
216
  #run: HerdrRunner;
134
217
  #settleMs: number;
135
218
  #sleep: (ms: number) => void;
136
219
  #readLines: number;
220
+ #markerPid: () => HerdrMarkerPid;
221
+ #env: Record<string, string | undefined>;
137
222
 
138
223
  constructor(opts: HerdrTransportOptions = {}) {
139
224
  this.#run = opts.run ?? defaultHerdrRunner;
@@ -143,7 +228,9 @@ export class HerdrTransport implements Transport {
143
228
  // the spike measured is Claude's input box; the retry exists for that, bounded to one.
144
229
  this.#settleMs = opts.settleMs ?? 400;
145
230
  this.#sleep = opts.sleep ?? ((ms) => { const end = Date.now() + ms; while (Date.now() < end) { /* spin: tiny and rare */ } });
146
- this.#readLines = opts.readLines ?? 12;
231
+ this.#readLines = opts.readLines ?? 40;
232
+ this.#markerPid = opts.markerPid ?? (() => herdrMarkerPid());
233
+ this.#env = opts.env ?? process.env;
147
234
  }
148
235
 
149
236
  /** Is herdr on this host AND is its server running? Both, or the reason. */
@@ -168,7 +255,7 @@ export class HerdrTransport implements Transport {
168
255
  async attach(args: { agentId: string; target?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number }): Promise<TransportMarker> {
169
256
  const avail = this.availability();
170
257
  if (!avail.available) throw new Error(`herdr transport cannot attach '${args.agentId}': ${avail.reason}`);
171
- let target = args.target ?? process.env.HERDR_PANE_ID;
258
+ let target = args.target ?? this.#env.HERDR_PANE_ID;
172
259
  if (!target) {
173
260
  const cur = this.#run(["pane", "current"]);
174
261
  target = paneOf(cur)?.pane_id;
@@ -178,10 +265,12 @@ export class HerdrTransport implements Transport {
178
265
  }
179
266
  const got = this.#run(["pane", "get", target]);
180
267
  if (!got.ok) throw new Error(`herdr pane '${target}' not found: ${got.error?.message ?? got.stderr}`);
268
+ const markerPid = this.#markerPid();
181
269
  return {
182
270
  agentId: args.agentId,
183
271
  transport: HERDR,
184
- pid: 0,
272
+ pid: markerPid.pid,
273
+ pidWhy: markerPid.why,
185
274
  target,
186
275
  tmuxTarget: target,
187
276
  since: Date.now(),
@@ -203,40 +292,108 @@ export class HerdrTransport implements Transport {
203
292
  * Type `text` into the pane and press enter; VERIFY by reading the pane back, and if
204
293
  * the line is still sitting unsubmitted (the spike's race), press enter once more.
205
294
  * Reports the enters it took so the race is measured on every delivery.
295
+ *
296
+ * ⟨q-dc83023a⟩ A DELIVERY IS A BRACKETED PASTE BY DEFAULT. `send-text` types keystrokes and
297
+ * herdr writes them in 1022-byte chunks; Claude Code v2.1.273 folds each large chunk into a
298
+ * `[Pasted text #N]` chip and, on submit, KEPT ONLY THE LAST CHUNK — a 2668-byte message
299
+ * arrived as its final 624 bytes, the PR, sha and gate gone. Wrapped in paste markers, the
300
+ * same bytes arrived whole (measured at 2668 B, 5 KB multi-line and 20 KB). The body is
301
+ * neutralised first so it can never carry the closing marker itself (⟨q-e5cb3538⟩).
302
+ * `paste: false` is for control commands only: a slash command must arrive as typing.
206
303
  */
207
- async push(marker: TransportMarker, text: string): Promise<{ delivered: boolean; error?: string; enters?: number; verified?: boolean }> {
304
+ async push(marker: TransportMarker, text: string, opts: { paste?: boolean } = {}): Promise<HerdrPushResult> {
208
305
  const avail = this.availability();
209
- if (!avail.available) return { delivered: false, error: avail.reason };
306
+ if (!avail.available) return { delivered: false, outcome: "held", safeToRetry: true, error: avail.reason };
210
307
  const target = targetOf(marker);
211
- if (!target) return { delivered: false, error: "no target recorded on the marker" };
212
- const typed = this.#run(["pane", "send-text", target, text]);
213
- if (!typed.ok) return { delivered: false, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
308
+ if (!target) return { delivered: false, outcome: "held", safeToRetry: true, error: "no target recorded on the marker" };
309
+ const payload = opts.paste === false ? text : `${PASTE_START}${neutralizeControls(text)}${PASTE_END}`;
214
310
  const enterKey = herdrKeyName("enter");
215
- if (!enterKey.ok) return { delivered: false, error: enterKey.error };
311
+ if (!enterKey.ok) return { delivered: false, outcome: "held", safeToRetry: true, error: enterKey.error };
312
+ if (readyProfile().profile === "none") return this.#pushUnguarded(target, text, payload, enterKey.key);
313
+
314
+ // ⟨q-15d763dc⟩ BEFORE ANY KEY: the screen must be Claude Code's ready, empty input box. The text
315
+ // is held as firmly as the Enter — a digit typed into an open dialog selects an option.
316
+ const before = readReadyBox(this.#screen(target));
317
+ const refusal = refusalBeforeKeys(before);
318
+ if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` };
319
+
320
+ const typed = this.#run(["pane", "send-text", target, payload]);
321
+ if (!typed.ok) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
322
+ this.#sleep(this.#settleMs);
323
+ let box = readReadyBox(this.#screen(target));
324
+ if (!boxHoldsPayload(box, text)) {
325
+ return { delivered: false, verified: false, outcome: "typed-unconfirmed", safeToRetry: false, error: `typed into ${target}, but the text did not show in the input box (${box.ready ? "box holds something else" : box.reason}) — Enter NOT sent` };
326
+ }
216
327
  let enters = 0;
217
328
  for (let attempt = 0; attempt < 2; attempt++) {
329
+ // Every Enter follows a read that saw THIS payload in the ready box, never a stale one.
218
330
  const pressed = this.#run(["pane", "send-keys", target, enterKey.key]);
219
- if (!pressed.ok) return { delivered: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
331
+ if (!pressed.ok) return { delivered: false, verified: false, outcome: "typed-unconfirmed", safeToRetry: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
332
+ enters++;
333
+ this.#sleep(this.#settleMs);
334
+ box = readReadyBox(this.#screen(target));
335
+ if (transcriptGained(before, box, text)) return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false, enters, busy: box.ready ? box.busy ?? undefined : undefined };
336
+ if (boxHoldsPayload(box, text)) continue;
337
+ return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, enters, error: `enter sent to ${target}, but the screen after it does not show the message submitted (${box.ready ? (box.draft ? "the box holds other text" : "the box is empty and the transcript gained no line carrying it") : box.reason})` };
338
+ }
339
+ return { delivered: false, verified: true, outcome: "pending", safeToRetry: false, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
340
+ }
341
+
342
+ /** The screen, styled, so the ready-box reader can tell a ghost suggestion from a draft. null = unreadable. */
343
+ #screen(target: string): string | null {
344
+ const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "ansi"]);
345
+ return read.ok ? read.stdout : null;
346
+ }
347
+
348
+ /**
349
+ * AGENT_COORD_READY_PROFILE=none, chosen by whoever started this process: #357's push, with no
350
+ * readiness check. Reported `unguarded`, and an unreadable screen still never counts as verified.
351
+ */
352
+ #pushUnguarded(target: string, text: string, payload: string, enter: string): HerdrPushResult {
353
+ const typed = this.#run(["pane", "send-text", target, payload]);
354
+ if (!typed.ok) return { delivered: false, unguarded: true, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
355
+ let enters = 0;
356
+ for (let attempt = 0; attempt < 2; attempt++) {
357
+ const pressed = this.#run(["pane", "send-keys", target, enter]);
358
+ if (!pressed.ok) return { delivered: false, unguarded: true, outcome: "typed-unconfirmed", safeToRetry: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
220
359
  enters++;
221
360
  this.#sleep(this.#settleMs);
222
361
  const pending = this.#stillPending(target, text);
223
- if (pending === false) return { delivered: true, enters, verified: true };
224
- if (pending === null) return { delivered: true, enters, verified: false };
362
+ if (pending === false) return { delivered: true, verified: true, unguarded: true, outcome: "delivered", safeToRetry: false, enters };
363
+ if (pending === null) return { delivered: false, verified: false, unguarded: true, outcome: "unverified", safeToRetry: false, enters, error: `enter sent to ${target}, but the pane could not be read back to verify it` };
225
364
  }
226
- return { delivered: false, enters, verified: true, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
365
+ return { delivered: false, verified: true, unguarded: true, outcome: "pending", safeToRetry: false, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
227
366
  }
228
367
 
229
368
  /**
230
- * Read the pane back: is the typed text still the LAST non-empty line (unsubmitted)?
369
+ * Read the pane back: is the typed text still sitting in the INPUT (unsubmitted)?
231
370
  * true = pending · false = submitted · null = could not read (unverified, not failed).
371
+ *
372
+ * ⟨q-dc83023a⟩ THE INPUT IS THE PROMPT LINE, NOT THE LAST LINE. Claude Code draws a border,
373
+ * a footer and hints ("paste again to expand") BELOW its `❯` input, so the last non-empty
374
+ * line is never the input and the old last-line check answered "submitted" for a message
375
+ * still sitting there. The prompt line is pending when it shows a paste chip or the start
376
+ * of what was typed (a long first line wraps, so either may be a prefix of the other).
377
+ *
378
+ * NO PROMPT LINE → null (UNVERIFIED), never the last-line reading: that reading was measured
379
+ * to answer "submitted" for text still sitting in Claude's input, so falling back to it would
380
+ * let a changed Claude version, an open dialog or a read mid-render verify silently again
381
+ * (the coordinator's condition on ⟨q-dc83023a⟩, from worker-2). What a caller does with an
382
+ * unverified delivery is ⟨q-15d763dc⟩.
232
383
  */
233
384
  #stillPending(target: string, text: string): boolean | null {
234
385
  const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "text"]);
235
386
  if (!read.ok) return null;
236
387
  const lines = read.stdout.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim().length > 0);
237
- const last = lines.at(-1) ?? "";
238
388
  const firstLine = text.split("\n")[0];
239
- return last.endsWith(firstLine) && !/^[⏺✔✖]/.test(last);
389
+ const prompt = [...lines].reverse().find((l) => /^\s*❯/.test(l));
390
+ if (prompt !== undefined) {
391
+ const content = prompt.replace(/^\s*❯\s?/, "").trim();
392
+ if (!content) return false;
393
+ if (PASTE_CHIP_RE.test(content)) return true;
394
+ return firstLine.startsWith(content) || content.startsWith(firstLine);
395
+ }
396
+ return null;
240
397
  }
241
398
 
242
399
  /**
@@ -276,7 +433,8 @@ export class HerdrTransport implements Transport {
276
433
  * not measured) and are said so in the result.
277
434
  */
278
435
  async sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string; enters?: number; note?: string }> {
279
- const r = await this.push(marker, `/${cmd}`);
436
+ const r = await this.push(marker, `/${cmd}`, { paste: false });
437
+ // push reports delivered only when verified (⟨q-15d763dc⟩), so `delivered` is the whole test here.
280
438
  if (!r.delivered) return { ok: false, error: r.error ?? "control not delivered", enters: r.enters };
281
439
  return {
282
440
  ok: true,
@@ -14,7 +14,7 @@ import { configuredTransport } from "./config.js";
14
14
  export * from "./types.js";
15
15
  export * from "./config.js";
16
16
  export { TmuxTransport, tmuxAvailable, paneExists, probePane, tmuxVersion, type TmuxHost } from "./tmux.js";
17
- export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult } from "./herdr.js";
17
+ export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, herdrMarkerPid, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult, type HerdrMarkerPid } from "./herdr.js";
18
18
 
19
19
  let host: TmuxHost | undefined;
20
20
 
@@ -87,6 +87,11 @@ export type TransportMarker = {
87
87
  target?: string;
88
88
  /** The original field. Still written. See `target`. */
89
89
  tmuxTarget?: string;
90
+ /**
91
+ * ⟨q-abd88dd4⟩ herdr only: why `pid` holds what it holds (see `herdrMarkerPid`). A herdr
92
+ * marker's pid is addressed to PRE-HERDR readers and is never liveness for this build.
93
+ */
94
+ pidWhy?: string;
90
95
  since: number;
91
96
  /**
92
97
  * Remote pushers run on a different machine; the local pid is meaningless,