agent-coord-mcp 0.26.36 → 0.26.40

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 (41) hide show
  1. package/dist/auth-faults.js +145 -0
  2. package/dist/auth-faults.js.map +1 -0
  3. package/dist/capabilities.js +113 -2
  4. package/dist/capabilities.js.map +1 -1
  5. package/dist/launch-unit.js +282 -0
  6. package/dist/launch-unit.js.map +1 -0
  7. package/dist/server.js +46 -7
  8. package/dist/server.js.map +1 -1
  9. package/dist/store.js +139 -1
  10. package/dist/store.js.map +1 -1
  11. package/dist/test-count-key.js +22 -0
  12. package/dist/test-count-key.js.map +1 -0
  13. package/dist/tools/away.js +80 -17
  14. package/dist/tools/away.js.map +1 -1
  15. package/dist/tools/events.js +77 -3
  16. package/dist/tools/events.js.map +1 -1
  17. package/dist/tools/herdr-delivery.js +51 -36
  18. package/dist/tools/herdr-delivery.js.map +1 -1
  19. package/dist/tools/herdr-tail.js +99 -77
  20. package/dist/tools/herdr-tail.js.map +1 -1
  21. package/dist/tools/registry.js +50 -3
  22. package/dist/tools/registry.js.map +1 -1
  23. package/dist/transports/herdr.js +92 -2
  24. package/dist/transports/herdr.js.map +1 -1
  25. package/package.json +1 -1
  26. package/scripts/check-test-count.mjs +80 -6
  27. package/scripts/coord-attention-clock.mjs +19 -21
  28. package/scripts/coord-stall-clock.mjs +23 -24
  29. package/scripts/lib/clock-unit-cli.mjs +134 -0
  30. package/src/auth-faults.ts +166 -0
  31. package/src/capabilities.ts +115 -2
  32. package/src/launch-unit.ts +350 -0
  33. package/src/server.ts +47 -6
  34. package/src/store.ts +146 -1
  35. package/src/test-count-key.ts +21 -0
  36. package/src/tools/away.ts +113 -28
  37. package/src/tools/events.ts +89 -4
  38. package/src/tools/herdr-delivery.ts +46 -31
  39. package/src/tools/herdr-tail.ts +84 -65
  40. package/src/tools/registry.ts +55 -3
  41. package/src/transports/herdr.ts +93 -2
@@ -38,9 +38,9 @@
38
38
  * `REFRESH_TICKS` ticks, and no herdr subprocess runs until there is something to type.
39
39
  */
40
40
  import { readFileSync, statSync } from "node:fs";
41
- import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
41
+ import { inboxFile, roomFile, getRooms, transportFile, withDeliveryLock } from "../store.js";
42
42
  import { activeTransport, HERDR, targetOf, type Transport, type TransportMarker } from "../transports/index.js";
43
- import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
43
+ import { renderForPane, advancePushCursor, readPushCursorFor, pushLogLine, sourceKey, type Message } from "./herdr-delivery.js";
44
44
  import { newMessagesIn, idEndingAt, endOfId } from "./jsonl-offsets.js";
45
45
 
46
46
  export const DEFAULT_POLL_MS = 1000;
@@ -84,7 +84,8 @@ export type TailContext = {
84
84
  };
85
85
  export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), hwId: new Map(), sizes: new Map(), seedEof, seeded: new Set(), pastEof: new Set() });
86
86
 
87
- const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
87
+ /** ONE spelling with the send-time path: the delivery lock is taken on this key by both (⟨q-524305dd⟩). */
88
+ const keyOf = (s: { kind: string; chan?: string }) => sourceKey(s.kind === "dm" ? { kind: "dm" } : { kind: "room", chan: s.chan! });
88
89
  /** Set the in-process mark just past `msg`, remembering which message it follows. */
89
90
  const mark = (ctx: TailContext, key: string, end: number, msg: Record<string, unknown>) => {
90
91
  ctx.hw.set(key, end);
@@ -193,71 +194,89 @@ export async function tailOnce(
193
194
  // now sits below it. So the mark is checked against the message it was set after: moved to where
194
195
  // that message now ends, or — if it was archived — dropped, and the PERSISTED cursor, which the
195
196
  // compactor rebased exactly (adjustCursors → rebaseBytes), is the position again.
196
- const held = ctx.hw.get(key);
197
- if (held !== undefined && idEndingAt(src.file, held) !== (ctx.hwId.get(key) ?? null)) {
198
- const id = ctx.hwId.get(key);
199
- const moved = id ? endOfId(src.file, id) : null;
200
- log(`[herdr-tail] ${key} for ${agentId} was rewritten beneath the in-process mark (byte ${held}, after ${id ?? "no id"}) — ${moved !== null ? `moved to byte ${moved}, where that message now ends` : "that message is gone; resuming from the rebased push cursor"}`);
201
- if (moved !== null) ctx.hw.set(key, moved);
202
- else { ctx.hw.delete(key); ctx.hwId.delete(key); }
203
- }
204
- const from = await offsetOf(agentId, src, ctx);
205
- // …and if the PERSISTED cursor is past EOF too, the compactor has not rebased it yet (it rewrites
206
- // the file first, then the cursors) or a server without this fix compacted. `size <= from` below
207
- // skips it either way; this only makes the state VISIBLE, once per entry — on 2026-09-24 the only
208
- // measurements of this defect came from a seat reading the room file by hand.
209
- if (from > size) {
210
- if (!ctx.pastEof.has(key)) log(`[herdr-tail] ${key} push cursor for ${agentId} is past EOF (${from} > ${size} bytes) — nothing can be typed from there until it is rebased or the file regrows past it (q-f44f17a7)`);
211
- ctx.pastEof.add(key);
212
- } else ctx.pastEof.delete(key);
213
- // ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
214
- // `size === from` — a source with nothing owed must stay silent, or every idle room on
215
- // every tick becomes a line and the log that is supposed to make a real stall visible
216
- // drowns in ones that are not.
217
- if (size <= from) continue;
218
-
219
- for (const { msg, end } of newMessagesIn(src.file, from)) {
220
- if (msg.from === agentId) { mark(ctx, key, end, msg); continue; }
221
- const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
222
- const rendered = await renderForPane(msg as unknown as Message, agentId, where);
223
- let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
224
- try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
225
- // ⟨q-1ce5bc97⟩ step 1(b) — EVERY push outcome, logged, before any branch decides what to do
226
- // with it. Not just the ones that end up `held`: a `delivered` needs a line too, or the
227
- // log can never distinguish "nothing was owed" from "something was pushed and it worked".
228
- log(
229
- `[herdr-tail] push id=${String(msg.id ?? "?")} target=${targetOf(marker) ?? "?"} outcome=${r.outcome ?? (r.delivered ? "delivered" : "unknown")}` +
230
- (r.error ? ` reason=${JSON.stringify(r.error)}` : ""),
231
- );
232
- if (!r.delivered && r.safeToRetry === true) {
233
- // HELD: the transport sent NO key (no ready input box, a draft, a dialog, an unreadable pane).
234
- // Nothing moves and the message is retried next tick, which costs only a screen read. Stop
235
- // this source so no later message is typed past one that did not land.
236
- out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: r.error ?? "held — nothing was typed" });
237
- ctx.sizes.delete(key); // force a re-read next tick
238
- break;
197
+ // ⛔ ⟨q-524305dd⟩ THE SAME LOCK THE SENDER'S SEND-TIME PUSH TAKES. Without it, this tick read the push
198
+ // cursor while a sender's server was mid-verify on the same message and typed it a second time. A
199
+ // tick never waits for it: whoever holds it is delivering, and the next tick re-reads the cursor.
200
+ const locked = await withDeliveryLock(agentId, key, async () => {
201
+ const held = ctx.hw.get(key);
202
+ if (held !== undefined && idEndingAt(src.file, held) !== (ctx.hwId.get(key) ?? null)) {
203
+ const id = ctx.hwId.get(key);
204
+ const moved = id ? endOfId(src.file, id) : null;
205
+ log(`[herdr-tail] ${key} for ${agentId} was rewritten beneath the in-process mark (byte ${held}, after ${id ?? "no id"}) — ${moved !== null ? `moved to byte ${moved}, where that message now ends` : "that message is gone; resuming from the rebased push cursor"}`);
206
+ if (moved !== null) ctx.hw.set(key, moved);
207
+ else { ctx.hw.delete(key); ctx.hwId.delete(key); }
239
208
  }
240
- // ⛔ ANYTHING SHORT OF A VERIFIED DELIVERY DOES NOT MOVE THE PERSISTENT CURSOR (⟨q-15d763dc⟩).
241
- // Keys may have reached the pane (text typed, an Enter pressed) without the screen proving the
242
- // message was submitted, so it stays owed on disk: read_messages still serves it and a restart
243
- // re-offers it. The in-process high-water mark alone stops it being re-typed every tick, since
244
- // re-typing a message that may already be sitting in the box is the paste loop in another coat.
245
- // A throw lands here too: nothing says whether a key went out before it.
246
- if (!r.delivered || r.verified !== true) {
209
+ const from = await offsetOf(agentId, src, ctx);
210
+ // …and if the PERSISTED cursor is past EOF too, the compactor has not rebased it yet (it rewrites
211
+ // the file first, then the cursors) or a server without this fix compacted. `size <= from` below
212
+ // skips it either way; this only makes the state VISIBLE, once per entry — on 2026-09-24 the only
213
+ // measurements of this defect came from a seat reading the room file by hand.
214
+ if (from > size) {
215
+ if (!ctx.pastEof.has(key)) log(`[herdr-tail] ${key} push cursor for ${agentId} is past EOF (${from} > ${size} bytes) — nothing can be typed from there until it is rebased or the file regrows past it (q-f44f17a7)`);
216
+ ctx.pastEof.add(key);
217
+ } else ctx.pastEof.delete(key);
218
+ // ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
219
+ // `size === from` — a source with nothing owed must stay silent, or every idle room on
220
+ // every tick becomes a line and the log that is supposed to make a real stall visible
221
+ // drowns in ones that are not.
222
+ if (size <= from) return;
223
+
224
+ for (const { msg, end } of newMessagesIn(src.file, from)) {
225
+ if (msg.from === agentId) { mark(ctx, key, end, msg); continue; }
226
+ const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
227
+ const rendered = await renderForPane(msg as unknown as Message, agentId, where);
228
+ let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
229
+ try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
230
+ // ⟨q-1ce5bc97⟩ step 1(b) — EVERY push outcome, logged, before any branch decides what to do
231
+ // with it. Not just the ones that end up `held`: a `delivered` needs a line too, or the
232
+ // log can never distinguish "nothing was owed" from "something was pushed and it worked".
233
+ log(pushLogLine("tail", msg.id, targetOf(marker), r.outcome ?? (r.delivered ? "delivered" : "unknown"), r.error));
234
+ // ⛔⛆ A `pending` IS RE-PUSHED, AND THAT IS THE CURE — NOT AN EXCLUSION. `pending` means the payload
235
+ // was typed and Enter pressed and it did NOT submit, so the composer now holds that text.
236
+ // ⚠ THE FIRST FIX HERE WAS TO SKIP THE RE-PUSH, and it was wrong: it stops the retype loop but the
237
+ // dirty composer then HOLDS every later message to that pane while the seat reads QUIET instead of
238
+ // WEDGED. Curing the loop is not curing the symptom.
239
+ // ⭐ What re-pushing now does depends on which transport this server loaded, and BOTH are correct:
240
+ // • NEW (this build): the transport recognises a payload IT left un-submitted and PRESSES ENTER
241
+ // rather than retyping — the message goes out on the next tick with no human involved.
242
+ // • OLD (0.26.36, what the fleet is still running): the push refuses on the draft, which is
243
+ // today's behaviour exactly, so this is no regression while the servers wait on a restart.
244
+ // ⛔ Either way it lands in `out.held` BELOW, so the state is VISIBLE every tick with the pane named
245
+ // and the human action spelled out. A silent park is the one outcome ruled out.
246
+ // ⛔ AND THE ENTER CANNOT LIVE HERE: a consumer cannot press a key, only ask the transport to push.
247
+ // Restarting the servers that would load it is David's call, not this slice's ⟨q-1ce5bc97⟩.
248
+ if (!r.delivered && r.safeToRetry === true) {
249
+ // NO VERIFIED DELIVERY and the transport says a retry could work: either nothing was typed (no
250
+ // ready input box, a foreign draft, a dialog, an unreadable pane) or the payload is sitting
251
+ // un-submitted in the box. Stop this source either way, so no later message is typed past one
252
+ // that did not land, and re-read next tick.
253
+ out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: r.error ?? (r.outcome === "pending" ? "pending — typed but not submitted" : "held — nothing was typed") });
254
+ ctx.sizes.delete(key); // force a re-read next tick
255
+ break;
256
+ }
257
+ // ⛔ ANYTHING SHORT OF A VERIFIED DELIVERY DOES NOT MOVE THE PERSISTENT CURSOR (⟨q-15d763dc⟩).
258
+ // Keys may have reached the pane (text typed, an Enter pressed) without the screen proving the
259
+ // message was submitted, so it stays owed on disk: read_messages still serves it and a restart
260
+ // re-offers it. The in-process high-water mark alone stops it being re-typed every tick, since
261
+ // re-typing a message that may already be sitting in the box is the paste loop in another coat.
262
+ // A throw lands here too: nothing says whether a key went out before it.
263
+ if (!r.delivered || r.verified !== true) {
264
+ mark(ctx, key, end, msg);
265
+ 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)` });
266
+ break;
267
+ }
268
+ // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
269
+ // cannot turn one delivery into a paste loop, then persist exactly past this line.
247
270
  mark(ctx, key, end, msg);
248
- 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)` });
249
- break;
271
+ const persisted = await advancePushCursor(agentId, where, end);
272
+ out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
273
+ if (!persisted) {
274
+ 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" });
275
+ break;
276
+ }
250
277
  }
251
- // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
252
- // cannot turn one delivery into a paste loop, then persist exactly past this line.
253
- mark(ctx, key, end, msg);
254
- const persisted = await advancePushCursor(agentId, where, end);
255
- out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
256
- if (!persisted) {
257
- 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" });
258
- break;
259
- }
260
- }
278
+ }, { wait: false, log });
279
+ if (!locked.ran) ctx.sizes.delete(key); // another deliverer holds this source: re-read on the next tick
261
280
  }
262
281
  return out;
263
282
  }
@@ -660,9 +660,61 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
660
660
  // for the fleet that runs on herdr: every seat was refused by its own leftover marker (6 of 6 on
661
661
  // 2026-09-23). The pane comes from THIS process's environment, never from the caller's argument.
662
662
  const ownPane = ownPaneTarget(marker.transport);
663
- if (targetOf(marker) && ownPane && targetOf(marker) === ownPane) {
664
- samePane = true;
665
- reasons.push(`the marker names THIS process's own pane (${ownPane})`);
663
+ const markerPane = targetOf(marker);
664
+ if (markerPane && ownPane) {
665
+ // ⟨q-f995c3c7⟩ NEXT LAYER — COMPARE PANE IDENTITIES, NOT SPELLINGS. `===` on the two strings was
666
+ // the whole remaining defect: one pane has more than one name, and this seat's env said
667
+ // `wA5:p1` while its own marker said `wA3:p4` for the SAME pane. Every herdr seat had to be
668
+ // hand-staged to its env spelling before a restart, and the standdown manifest grew an
669
+ // `attachWith` field to carry the workaround. herdr already answers the canonical id for
670
+ // either name, so the fix is to ask it.
671
+ const resolver = activeTransport();
672
+ const canAsk = marker.transport === HERDR && resolver?.kind === HERDR && resolver instanceof HerdrTransport;
673
+ const mine = canAsk ? resolver.resolvePaneId(ownPane) : null;
674
+ const theirs = canAsk ? resolver.resolvePaneId(markerPane) : null;
675
+
676
+ if (mine && theirs) {
677
+ // Both spellings resolved: identity is the answer, and a spelling mismatch is no longer one.
678
+ if (mine === theirs) {
679
+ samePane = true;
680
+ reasons.push(
681
+ markerPane === ownPane
682
+ ? `the marker names THIS process's own pane (${ownPane})`
683
+ : `the marker names THIS process's own pane — '${markerPane}' and '${ownPane}' both resolve to ${mine}`,
684
+ );
685
+ }
686
+ } else if (markerPane === ownPane) {
687
+ // ⛔ THE FALLBACK, AND IT IS DELIBERATELY THE NARROW ONE. When herdr cannot be asked — a tmux
688
+ // seat, no herdr binary, a pane that no longer answers — the only thing still knowable is
689
+ // whether the two spellings are literally equal, which is exactly the pre-fix behaviour. It
690
+ // is kept so this change cannot REMOVE a reclaim that used to work.
691
+ //
692
+ // ⛔⛆ AND IT NEVER WIDENS: "could not ask" must not silently become "matches". Unequal
693
+ // spellings that could not be resolved stay UNMATCHED — the seat is refused and has to be
694
+ // looked at, which is the outcome this row exists to make rare, not the outcome it exists to
695
+ // hide. A fallback that guessed `true` here would convert a diagnosable refusal into a seat
696
+ // silently claiming a pane that may belong to someone else.
697
+ samePane = true;
698
+ reasons.push(
699
+ canAsk
700
+ ? `the marker names THIS process's own pane (${ownPane}) — spellings are equal; herdr could not resolve either to a pane id, so identity was NOT confirmed`
701
+ : `the marker names THIS process's own pane (${ownPane})`,
702
+ );
703
+ } else {
704
+ // ⛔ STATED RATHER THAN SILENT, AND IT DISTINGUISHES THE TWO WAYS OF NOT KNOWING. A refusal
705
+ // whose reason is "I could not ask" is diagnosable in one read; one that prints nothing is the
706
+ // 26-hour kind. The first version of this branch was `else if (canAsk)`, which left the
707
+ // no-resolver case saying NOTHING — the same invisible-fallback shape the row is about, one
708
+ // level down, and the tests caught it.
709
+ const why = !canAsk
710
+ ? marker.transport === HERDR
711
+ ? "no herdr transport is wired in this process (initTransportFromConfig has not run), so no pane id could be asked for"
712
+ : `the ${marker.transport} transport has no pane-identity resolver, so only the spellings could be compared`
713
+ : `herdr could not resolve ${mine ? `'${markerPane}'` : theirs ? `'${ownPane}'` : "either spelling"} to a pane id`;
714
+ reasons.push(
715
+ `the marker names pane '${markerPane}' and this process is in '${ownPane}'; ${why}, so they are NOT treated as the same pane`,
716
+ );
717
+ }
666
718
  }
667
719
  }
668
720
 
@@ -246,6 +246,20 @@ export class HerdrTransport implements Transport {
246
246
  #markerPid: () => HerdrMarkerPid;
247
247
  #env: Record<string, string | undefined>;
248
248
  #submitMode: SubmitMode;
249
+ /**
250
+ * ⛔⛆ PROVENANCE FOR THE ENTER WE PRESS ON A DRAFT WE DID NOT JUST TYPE: target → the exact text this
251
+ * transport left sitting un-submitted in that pane. A `pending` result means the payload is IN the box
252
+ * and our enters did not submit it; the recovery is to press Enter again, NEVER to retype.
253
+ * ⚠ Why the box alone cannot decide this: a pasted payload renders as `[Pasted text #N +M lines]`, and
254
+ * `isPasteChip` is CONTENT-BLIND (MEASURED 2026-09-27, 4 cases over the 4 draft classes readReadyBox
255
+ * distinguishes — our verbatim payload, a foreign plain draft, a foreign chip, an empty box: the foreign
256
+ * chip read as OURS). Our own paste is a chip too, so chip-vs-chip is indistinguishable ON SCREEN and a
257
+ * content-only rule would refuse the very case this fixes. So ownership is recorded HERE, by the code
258
+ * that typed it, and the screen read stays exactly as strict as it was.
259
+ * ⭐ In-memory by design: after a restart we claim nothing and fall through to the refusal, which is the
260
+ * conservative side and is what today's build already does.
261
+ */
262
+ #leftUnsubmitted = new Map<string, string>();
249
263
 
250
264
  constructor(opts: HerdrTransportOptions = {}) {
251
265
  this.#run = opts.run ?? defaultHerdrRunner;
@@ -329,11 +343,61 @@ export class HerdrTransport implements Transport {
329
343
  return herdrPaneExists(target, this.#run);
330
344
  }
331
345
 
346
+ /**
347
+ * ⟨q-f995c3c7⟩ next layer — THE CANONICAL ID BEHIND A PANE SPELLING. `null` = could not ask.
348
+ *
349
+ * ⛔ WHY THIS EXISTS: one pane has more than one name. This seat's env said `wA5:p1` while its
350
+ * marker said `wA3:p4`, both resolving to the same pane, and the first-claim guard compared the two
351
+ * STRINGS with `===`. So a seat restarting into its own pane was refused by its own marker — the
352
+ * defect ⟨q-f995c3c7⟩ fixed for the ENV-vs-TMUX_PANE case and left standing for spelling-vs-spelling.
353
+ * Measured 2026-09-26: six seats had to be hand-staged to the env spelling before a restart, and the
354
+ * fleet-wide standdown manifest now carries an `attachWith` field to work around it.
355
+ *
356
+ * ⭐ HERDR IS ALREADY THE RESOLVER — `pane get <spelling>` answers with the canonical `pane_id` for
357
+ * either name, and `attach()` above has read `paneOf(cur)?.pane_id` since before this row existed.
358
+ * Nothing new is being invented here; the guard simply stopped short of asking.
359
+ */
360
+ resolvePaneId(target: string): string | null {
361
+ const got = this.#run(["pane", "get", target]);
362
+ if (!got.ok) return null;
363
+ return paneOf(got)?.pane_id ?? null;
364
+ }
365
+
332
366
  /** Nothing to kill: there is no pusher. The tool deletes the marker. */
333
367
  async detach(_agentId: string): Promise<void> {
334
368
  return;
335
369
  }
336
370
 
371
+ /**
372
+ * Press Enter on a payload ALREADY sitting in the ready box, and verify by reading back. Returns a
373
+ * result when the outcome is settled, or `null` to fall through to the normal type-then-submit path.
374
+ *
375
+ * ⭐ Used only when `boxHoldsPayload` says the draft is OURS. No `send-text` happens here — the text is
376
+ * already there, and typing it again would join it to itself. This is the recovery for a `pending` left
377
+ * by an earlier attempt, and it is what turns that state from "a human must nudge it" into "the next
378
+ * tick submits it".
379
+ */
380
+ #pressEnterUntilSubmitted(target: string, text: string, before: ReturnType<typeof readReadyBox>, key: string): HerdrPushResult | null {
381
+ let enters = 0;
382
+ for (let attempt = 0; attempt < 2; attempt++) {
383
+ const pressed = this.#run(["pane", "send-keys", target, key]);
384
+ if (!pressed.ok) return { delivered: false, verified: false, outcome: "typed-unconfirmed", safeToRetry: false, enters, error: `send-keys enter to ${target} refused while submitting a payload already in the box: ${pressed.error?.message ?? pressed.stderr}` };
385
+ enters++;
386
+ this.#sleep(this.#settleMs);
387
+ const box = readReadyBox(this.#screen(target));
388
+ if (transcriptGained(before, box, text)) {
389
+ this.#leftUnsubmitted.delete(target);
390
+ return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false, enters, busy: box.ready ? box.busy ?? undefined : undefined };
391
+ }
392
+ if (boxHoldsPayload(box, text)) continue;
393
+ // The box no longer holds our text and the transcript did not gain it: something else consumed it.
394
+ // Not ours to retype — report it rather than guess.
395
+ this.#leftUnsubmitted.delete(target);
396
+ return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, enters, error: `pressed enter on a payload already in ${target}'s box, but the screen after it does not show the message submitted (${box.ready ? (box.draft ? "the box now holds other text" : "the box is empty and the transcript gained no line carrying it") : box.reason})` };
397
+ }
398
+ return { delivered: false, verified: true, outcome: "pending", safeToRetry: true, enters, error: `payload was already sitting in ${target}'s input and ${enters} further enters did not submit it — the composer HOLDS this message: a human Enter in ${target} submits it, or clearing that box discards it` };
399
+ }
400
+
337
401
  /**
338
402
  * Type `text` into the pane and press enter; VERIFY by reading the pane back, and if
339
403
  * the line is still sitting unsubmitted (the spike's race), press enter once more.
@@ -363,6 +427,22 @@ export class HerdrTransport implements Transport {
363
427
  // ⟨q-15d763dc⟩ BEFORE ANY KEY: the screen must be Claude Code's ready, empty input box. The text
364
428
  // is held as firmly as the Enter — a digit typed into an open dialog selects an option.
365
429
  const before = readReadyBox(this.#screen(target));
430
+
431
+ // ⛔⛆ OUR OWN UN-SUBMITTED PAYLOAD IS SUBMITTED, NOT REFUSED AND NOT RETYPED. A previous attempt can
432
+ // leave this exact text in the composer: `pasteAndSubmit` types it, presses Enter up to twice, and
433
+ // returns `pending` when the box still holds it. `refusalBeforeKeys` then refuses ANY draft — so the
434
+ // retry refused on text THIS TRANSPORT typed, every tick, forever, and only a human Enter broke it.
435
+ // That is David's nudge on fleet HRx, shipped in 0.26.36 ⟨q-1ce5bc97⟩.
436
+ // ⭐ `boxHoldsPayload` is the discriminator worker-1 identified: OUR payload → press Enter and submit
437
+ // what is already there; a FOREIGN draft → refuse, which is what that refusal was always for. Typing
438
+ // again is never right — it would join the text to itself.
439
+ // ⚠ The refusal is NOT weakened for any other case: an unready screen, a dialog, or someone else's
440
+ // draft still stops before a single key, because `boxHoldsPayload` returns false for all of them.
441
+ if (this.#leftUnsubmitted.get(target) === text && boxHoldsPayload(before, text)) {
442
+ const resubmitted = this.#pressEnterUntilSubmitted(target, text, before, enterKey.key);
443
+ if (resubmitted) return resubmitted;
444
+ }
445
+
366
446
  const refusal = refusalBeforeKeys(before);
367
447
  if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` };
368
448
 
@@ -381,11 +461,22 @@ export class HerdrTransport implements Transport {
381
461
  enters++;
382
462
  this.#sleep(this.#settleMs);
383
463
  box = readReadyBox(this.#screen(target));
384
- if (transcriptGained(before, box, text)) return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false, enters, busy: box.ready ? box.busy ?? undefined : undefined };
464
+ if (transcriptGained(before, box, text)) {
465
+ this.#leftUnsubmitted.delete(target);
466
+ return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false, enters, busy: box.ready ? box.busy ?? undefined : undefined };
467
+ }
385
468
  if (boxHoldsPayload(box, text)) continue;
469
+ this.#leftUnsubmitted.delete(target);
386
470
  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})` };
387
471
  }
388
- return { delivered: false, verified: true, outcome: "pending", safeToRetry: true, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
472
+ // ⚠ `safeToRetry: true` STAYS, and it is not an oversight — ⟨q-1ce5bc97⟩ step 1 chose it because
473
+ // pending is the most recoverable non-delivery: the payload is KNOWN to be in the box, so a transient
474
+ // misread must not become a permanent drop. What was wrong lived in the CONSUMER, which read this flag
475
+ // as "no key was sent" and retried into the draft this attempt created (the 0.26.36 livelock).
476
+ // ⭐ The error now says what a sender actually faces, because `verified: true` here means the OUTCOME
477
+ // is known — we read the screen back and the payload is sitting there — and has never meant delivered.
478
+ this.#leftUnsubmitted.set(target, text);
479
+ return { delivered: false, verified: true, outcome: "pending", safeToRetry: true, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters — the pane's composer HOLDS this message, so a retype would join it rather than send it: a human Enter in ${target} submits it, or clearing that box discards it` };
389
480
  }
390
481
 
391
482
  /**