agent-coord-mcp 0.26.23 → 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 (63) hide show
  1. package/dist/capabilities.js +180 -5
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/gated-head.js +172 -6
  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/away.js +20 -2
  10. package/dist/tools/away.js.map +1 -1
  11. package/dist/tools/herdr-delivery.js +86 -26
  12. package/dist/tools/herdr-delivery.js.map +1 -1
  13. package/dist/tools/herdr-tail.js +221 -0
  14. package/dist/tools/herdr-tail.js.map +1 -0
  15. package/dist/tools/jsonl-offsets.js +37 -0
  16. package/dist/tools/jsonl-offsets.js.map +1 -0
  17. package/dist/tools/messaging.js +18 -0
  18. package/dist/tools/messaging.js.map +1 -1
  19. package/dist/tools/queue-write.js +4 -1
  20. package/dist/tools/queue-write.js.map +1 -1
  21. package/dist/tools/record-events.js +42 -4
  22. package/dist/tools/record-events.js.map +1 -1
  23. package/dist/tools/records.js +24 -5
  24. package/dist/tools/records.js.map +1 -1
  25. package/dist/tools/registry.js +56 -21
  26. package/dist/tools/registry.js.map +1 -1
  27. package/dist/tools/stall.js +73 -9
  28. package/dist/tools/stall.js.map +1 -1
  29. package/dist/tools/tick.js +77 -0
  30. package/dist/tools/tick.js.map +1 -0
  31. package/dist/tools/transport.js +4 -3
  32. package/dist/tools/transport.js.map +1 -1
  33. package/dist/transports/herdr.js +203 -19
  34. package/dist/transports/herdr.js.map +1 -1
  35. package/dist/transports/index.js +1 -1
  36. package/dist/transports/index.js.map +1 -1
  37. package/dist/transports/types.js +41 -0
  38. package/dist/transports/types.js.map +1 -1
  39. package/hooks/control-bytes.mjs +69 -0
  40. package/hooks/submit.mjs +227 -9
  41. package/hooks/tier.mjs +7 -1
  42. package/hooks/tmux-pusher.mjs +5 -1
  43. package/package.json +1 -1
  44. package/scripts/coord-pusher.mjs +5 -1
  45. package/src/capabilities.ts +183 -4
  46. package/src/gated-head.ts +193 -6
  47. package/src/server-spread.ts +52 -4
  48. package/src/server.ts +38 -1
  49. package/src/tools/away.ts +38 -2
  50. package/src/tools/herdr-delivery.ts +92 -24
  51. package/src/tools/herdr-tail.ts +244 -0
  52. package/src/tools/jsonl-offsets.ts +29 -0
  53. package/src/tools/messaging.ts +20 -0
  54. package/src/tools/queue-write.ts +4 -1
  55. package/src/tools/record-events.ts +46 -4
  56. package/src/tools/records.ts +24 -4
  57. package/src/tools/registry.ts +56 -20
  58. package/src/tools/stall.ts +80 -12
  59. package/src/tools/tick.ts +117 -0
  60. package/src/tools/transport.ts +5 -4
  61. package/src/transports/herdr.ts +251 -19
  62. package/src/transports/index.ts +1 -1
  63. package/src/transports/types.ts +71 -0
@@ -34,8 +34,18 @@
34
34
  * same reason (identical evidence for a typo and a default).
35
35
  */
36
36
  import { spawnSync } from "node:child_process";
37
- import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker } from "./types.js";
38
- import { HERDR, targetOf } from "./types.js";
37
+ import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
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,
@@ -285,6 +443,80 @@ export class HerdrTransport implements Transport {
285
443
  };
286
444
  }
287
445
 
446
+ /**
447
+ * ⟨q-1c95f7d4⟩ 5.1 — THE EXTERNAL TICK, CONSUMED. herdr's own `agent_status` for the
448
+ * pane, which is the signal this fleet has never had: our liveness is pid-existence and
449
+ * our activity is VCS commits, so a THINKING lane and a WEDGED lane are identical to us.
450
+ *
451
+ * ⛔ `unknown` AND `done` ARE NOT SEAT STATES AND MUST NOT READ AS CALM. `unknown` is
452
+ * herdr saying it has no agent there (the shape a plain shell pane returns, measured);
453
+ * `done` is a finished session, not a moving seat. Both come back UNREADABLE, so the
454
+ * caller counts them as unmeasured rather than crediting coverage — the exact inversion
455
+ * (blind read as quiet) that `stall_check` shipped and this task exists to end.
456
+ */
457
+ async readTick(marker: TransportMarker): Promise<TickReading> {
458
+ if (marker.transport !== HERDR) return { readable: false, why: `transport "${marker.transport}" has no external tick — only a herdr marker does` };
459
+ const target = targetOf(marker);
460
+ if (!target) return { readable: false, why: "no target recorded on the marker" };
461
+ const avail = this.availability();
462
+ if (!avail.available) return { readable: false, why: avail.reason };
463
+ const got = this.#run(["pane", "get", target]);
464
+ if (!got.ok) {
465
+ if (got.error?.code === "pane_not_found") return { readable: false, why: `herdr reports pane ${target} not found — a gone pane has no tick (and probe calls it dead)` };
466
+ return { readable: false, why: `herdr could not describe pane ${target}: ${got.error?.message ?? got.stderr}` };
467
+ }
468
+ const status = String(paneOf(got)?.agent_status ?? "unknown");
469
+ const state = TICK_READS_AS[status];
470
+ if (state) return { readable: true, state, source: `herdr pane ${target}` };
471
+ return { readable: false, why: `herdr reports agent_status "${status}" for pane ${target} — no agent state is published there, which is a MISSING signal, not a calm seat` };
472
+ }
473
+
474
+ /**
475
+ * ⟨q-1c95f7d4⟩ Task 1's open question — PUBLISH, and it needs a receipt it does not get.
476
+ *
477
+ * MEASURED 2026-09-15 in a workspace of my own: `herdr pane report-agent <pane> --source
478
+ * coord-mcp --agent <label> --state blocked` prints NOTHING and exits 0, and the pane
479
+ * then reads `"agent":"<label>","agent_status":"blocked"` where a second earlier it read
480
+ * `unknown` with no agent field. ⛔ A WRITE WITH NO OUTPUT IS A WRITE WITH NO RECEIPT:
481
+ * exit 0 here says the CLI parsed the arguments, not that the pane carries the state, and
482
+ * a second `report-agent` overwrites the first with the same silence. So this method does
483
+ * not trust the exit code — it READS THE PANE BACK and reports `ok` only when the pane
484
+ * carries both the agent label we published and the state we published. An unverifiable
485
+ * write is returned as an error naming what the pane actually says.
486
+ *
487
+ * ⚠ WHAT A WRONG PUBLISH COSTS, stated because the write is silent and destructive:
488
+ * herdr keeps ONE agent state per pane, so publishing to a pane this bus does not own
489
+ * overwrites whatever that pane's real agent last reported, with no history and no
490
+ * notification to the owner — a fleet watching herdr would then read our fiction as the
491
+ * seat's own signal. Hence the marker gate above (`transport !== HERDR` refuses): we
492
+ * publish only to panes recorded as herdr seats of this bus, never to a pane id passed in
493
+ * from anywhere else.
494
+ */
495
+ async publishTick(marker: TransportMarker, state: TickState, opts: { message?: string; source?: string } = {}): Promise<{ ok: boolean; error?: string; verified?: boolean }> {
496
+ if (marker.transport !== HERDR) return { ok: false, error: `refusing to publish state for a "${marker.transport}" seat — herdr keeps one agent state per pane and a publish to a pane this bus does not own would silently overwrite its owner's signal` };
497
+ const target = targetOf(marker);
498
+ if (!target) return { ok: false, error: "no target recorded on the marker" };
499
+ const avail = this.availability();
500
+ if (!avail.available) return { ok: false, error: avail.reason };
501
+ const source = opts.source ?? "coord-mcp";
502
+ const args = ["pane", "report-agent", target, "--source", source, "--agent", marker.agentId, "--state", state];
503
+ if (opts.message) args.push("--message", opts.message);
504
+ const wrote = this.#run(args);
505
+ if (!wrote.ok) return { ok: false, error: `herdr refused the report: ${wrote.error?.message ?? wrote.stderr}` };
506
+ // THE RECEIPT IS THE READ-BACK, never the exit code.
507
+ const back = this.#run(["pane", "get", target]);
508
+ if (!back.ok) return { ok: false, verified: false, error: `published, but the pane could not be read back to verify it: ${back.error?.message ?? back.stderr}` };
509
+ const pane = paneOf(back);
510
+ const gotAgent = String(pane?.agent ?? "");
511
+ const gotState = String(pane?.agent_status ?? "");
512
+ // VERIFIED AGAINST WHAT herdr STORES, not against what we sent: a published `idle`
513
+ // comes back as `done` (TICK_STORED_AS, measured). Comparing to the sent word would
514
+ // call every idle publish a failure; comparing to the stored word is the receipt.
515
+ const expected = TICK_STORED_AS[state];
516
+ if (gotAgent === marker.agentId && gotState === expected) return { ok: true, verified: true };
517
+ return { ok: false, verified: false, error: `published --agent ${marker.agentId} --state ${state} (herdr stores that as "${expected}"), but pane ${target} reads agent "${gotAgent || "(none)"}" state "${gotState || "(none)"}" — the write did not land as published` };
518
+ }
519
+
288
520
  /**
289
521
  * No pusher to kill. A herdr marker whose pane is gone is reaped (the caller deletes
290
522
  * the marker); an unknown answer is unprobeable; a live pane is left alone.
@@ -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,
@@ -154,6 +159,53 @@ export type Liveness =
154
159
  | { state: "dead"; reason: string }
155
160
  | { state: "unknown"; reason: string };
156
161
 
162
+ /**
163
+ * ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — THE EXTERNAL TICK'S VOCABULARY, TAKEN FROM THE TOOL THAT
164
+ * OWNS IT. Measured 2026-09-15 in a workspace of my own: `herdr pane report-agent --help`
165
+ * enumerates exactly `idle, working, blocked, unknown`, and a pane carries the state an
166
+ * external source published (`"agent":"<label>","agent_status":"blocked"` on a pane that
167
+ * read `unknown` a second earlier). So these four are herdr's own set, not ours.
168
+ *
169
+ * ⛔ `unknown` IS THE ABSENCE OF A SIGNAL, NOT A STATE OF THE SEAT. A tick that reads
170
+ * `unknown` is UNREADABLE — reported as such and counted as unmeasured — because the whole
171
+ * defect this task answers is a fleet that reads CALM when it is BLIND (Task 5.3).
172
+ */
173
+ export const TICK_STATES = ["idle", "working", "blocked"] as const;
174
+ export type TickState = (typeof TICK_STATES)[number];
175
+
176
+ /**
177
+ * ⛔⛆ THE WRITE VOCABULARY AND THE READ VOCABULARY ARE NOT THE SAME, AND ONLY A READ-BACK
178
+ * FINDS IT. Measured 2026-09-16 on a fresh pane of my own, six writes and three repeats:
179
+ *
180
+ * published --state working → agent_status "working"
181
+ * published --state blocked → agent_status "blocked"
182
+ * published --state idle → agent_status "done" ⬅ 3 of 3, with and without --seq
183
+ * published --state unknown → agent_status "unknown"
184
+ *
185
+ * herdr ACCEPTS `idle` (its own `--help` lists it) and STORES `done`. Nothing in the CLI
186
+ * says so: the write prints nothing and exits 0 either way. This is exactly what the
187
+ * receipt exists to catch, and it was caught by the read-back failing, not by reasoning.
188
+ *
189
+ * For the tick's purpose the two mean the same thing — a seat that is not working — so a
190
+ * `done` READS as `idle`, and a publish of `idle` is verified against `done`. The
191
+ * translation is named here rather than hidden in a comparison, so a herdr release that
192
+ * changes it fails a test instead of quietly inverting a verdict.
193
+ *
194
+ * ⚠ THE TWO DIRECTIONS ARE NOT THE SAME SET, AND THE `done` ENTRY IS NOT DEAD CODE — qa
195
+ * measured the asymmetry from the other side on herdr 0.9.0: `report-agent --state done`
196
+ * is REFUSED ("expected idle, working, blocked, or unknown"), so nothing we publish can
197
+ * produce it. It is still REACHABLE ON READ, which is the side `TICK_READS_AS` serves:
198
+ * `agent_status` is what a pane REPORTS, and herdr's own `agent wait --until` enumerates
199
+ * `idle, working, blocked, done, unknown`. A herdr-managed Claude session that finishes
200
+ * hands us `done` without anyone publishing it. So: refused on write, reachable on read,
201
+ * measured in both directions — do not delete this entry as unreachable.
202
+ */
203
+ export const TICK_STORED_AS: Readonly<Record<TickState, string>> = Object.freeze({ idle: "done", working: "working", blocked: "blocked" });
204
+ export const TICK_READS_AS: Readonly<Record<string, TickState>> = Object.freeze({ idle: "idle", done: "idle", working: "working", blocked: "blocked" });
205
+ export type TickReading =
206
+ | { readable: true; state: TickState; source: string }
207
+ | { readable: false; why: string };
208
+
157
209
  /** Keystroke-shaped commands a transport must deliver to an interactive pane. */
158
210
  export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"] as const;
159
211
  export type ControlCommand = (typeof CONTROL_COMMANDS)[number];
@@ -198,4 +250,23 @@ export interface Transport {
198
250
  sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }>;
199
251
 
200
252
  reapWedged(markers: TransportMarker[]): Promise<{ reaped: string[]; unprobeable: string[] }>;
253
+
254
+ /**
255
+ * ⟨q-1c95f7d4⟩ 5.1 — OPTIONAL, AND ITS ABSENCE IS THE HONEST ANSWER FOR tmux.
256
+ *
257
+ * A transport implements `readTick` only when something OUTSIDE this process observes
258
+ * the seat and can be asked. tmux cannot: a pane is a terminal, and "a pid exists" is
259
+ * what `probe` already says. Declaring the method optional is what keeps `stall_check`
260
+ * from inventing a tick for a tmux seat and reading a fabricated calm — those seats keep
261
+ * the unmeasurable story they had (5.3).
262
+ */
263
+ readTick?(marker: TransportMarker): Promise<TickReading>;
264
+
265
+ /**
266
+ * ⟨q-1c95f7d4⟩ Task 1's open question, answered by measurement: THE PIPE RUNS BOTH WAYS.
267
+ * `coord-mcp` knows what herdr cannot infer — which seat holds which lane, whether a
268
+ * DONE: awaits a verdict, whether a seat is parked on a David decision — so a transport
269
+ * that can PUBLISH state to its external observer exposes it here.
270
+ */
271
+ publishTick?(marker: TransportMarker, state: TickState, opts?: { message?: string; source?: string }): Promise<{ ok: boolean; error?: string }>;
201
272
  }