/** * THE HERDR TRANSPORT — the second implementation of the seam (Phase 5.4 Task 4). * * herdr is a RUST BINARY (brew / herdr.dev / GitHub releases), NOT an npm package: * `npm view herdr` answers 0.0.0 "Reserved package name" (spike 1.1). Nothing here is * imported; every call is `herdr ` over the socket API on this host, through * one injectable runner so the transport can be exercised without a herdr server and * measured against one when it is there. * * WHAT IS DIFFERENT FROM TMUX, and why each difference is a stated behaviour rather * than a quiet default: * · NO PUSHER PROCESS. Delivery is a socket call made by the server itself; a marker * carries pid 0. Liveness is therefore never a pid heuristic — `probe` asks herdr * for the pane and its OWN `agent_status` (spike 1.4: live / wedged / killed are * three distinguishable answers there, which they are not on tmux). * · ERRORS ARE JSON, ON EITHER STREAM. `herdr pane get w999:p1` prints * {"error":{"code":"pane_not_found",…}} — measured on stdout with exit 0 in one call * and on stderr in another (a pane closed moments before read as "could not describe" * until the runner parsed stderr too). The runner parses whichever stream carries a * JSON body; the exit code alone would call failures successes. * · KEY NAMES ARE NOT PORTABLE. `ctrl+u` is accepted, `C-u` / `ctrl-u` are rejected * with {"error":{"code":"invalid_key"}} (spike, and measured again here). The seam * takes INTENTS; the one place a key name is spelled is `herdrKeyName`, which * REFUSES a tmux-vocabulary name rather than translating it by guess. * · THE ENTER RACE IS REAL. `send-text` then `send-keys enter` did not submit once in * the spike; the line sat in Claude's input box until a second enter. `push` and * `sendControl` VERIFY by reading the pane back and retry the enter once, and report * how many enters it took — measured per delivery, never assumed away. * · THE SLASH IS EATEN DOWNSTREAM OF ANY TRANSPORT (spike 1.3, ⟨q-f14692ca⟩ struck): * Claude Code interprets a leading `/` however the characters arrive. This transport * delivers bytes verbatim and makes no claim about what the reader does with them. * · ABSENT BINARY / STOPPED SERVER → an explicit refusal that NAMES herdr. Never a * silent fall-through to tmux: the config layer already refuses unknown kinds for the * same reason (identical evidence for a typo and a default). */ import { spawnSync } from "node:child_process"; import type { ControlCommand, Liveness, PaneRead, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js"; import { HERDR, TICK_READS_AS, TICK_STORED_AS, targetOf } from "./types.js"; // ⟨q-dc83023a⟩ The chip grammar and the pane-unsafe byte class are single-sourced in hooks/, shared // with both pushers. `hooks/` ships beside `dist/`, so these resolve the same when installed. // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS import { PASTE_CHIP_RE, readReadyBox, readyProfile, refusalBeforeKeys, boxHoldsPayload, transcriptGained } from "../../hooks/submit.mjs"; // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS import { neutralizeControls } from "../../hooks/control-bytes.mjs"; /** Bracketed-paste markers (xterm mode 2004), which Claude Code and tmux `paste-buffer -p` both speak. */ export const PASTE_START = "\x1b[200~"; export const PASTE_END = "\x1b[201~"; export type HerdrError = { code: string; message: string }; export type HerdrResult = { ok: boolean; status: number | null; stdout: string; stderr: string; /** Parsed JSON body when herdr printed one. */ json?: unknown; /** herdr's own error object, or a synthetic one for a missing binary. */ error?: HerdrError; /** The binary itself is not on PATH. */ absent?: boolean; }; export type HerdrRunner = (args: string[]) => HerdrResult; export const HERDR_BINARY = "herdr"; export const HERDR_ABSENT_MESSAGE = `herdr binary not found on PATH — herdr is a Rust binary (brew install herdr, or https://herdr.dev), ` + `NOT an npm package (npm's "herdr" is a reserved 0.0.0 name). Install it, or use the tmux-push transport.`; /** * Interpret a herdr reply — PURE, so the one rule it holds (a JSON error body on either * stream is a refusal, whatever the exit code) is testable without a herdr on the host. */ export function interpretHerdrReply(r: { status: number | null; stdout?: string | null; stderr?: string | null }): HerdrResult { const stdout = r.stdout ?? ""; const stderr = r.stderr ?? ""; let json: unknown; for (const stream of [stdout, stderr]) { const trimmed = stream.trim(); if (!trimmed.startsWith("{")) continue; try { json = JSON.parse(trimmed); break; } catch { /* not a JSON body */ } } const err = (json as { error?: HerdrError } | undefined)?.error; if (err && typeof err.code === "string") return { ok: false, status: r.status, stdout, stderr, json, error: err }; if (r.status !== 0) return { ok: false, status: r.status, stdout, stderr, json, error: { code: "exit", message: (stderr || stdout).trim() || `herdr exited ${r.status}` } }; return { ok: true, status: r.status, stdout, stderr, json }; } /** Run `herdr ` and interpret the reply. */ export function defaultHerdrRunner(args: string[]): HerdrResult { const r = spawnSync(HERDR_BINARY, args, { encoding: "utf8" }); if (r.error && (r.error as NodeJS.ErrnoException).code === "ENOENT") { return { ok: false, status: null, stdout: "", stderr: "", absent: true, error: { code: "binary_absent", message: HERDR_ABSENT_MESSAGE } }; } return interpretHerdrReply({ status: r.status, stdout: r.stdout, stderr: r.stderr }); } /** * THE KEY VOCABULARY, in one place. Intents on the left, herdr's names on the right. * A tmux-vocabulary name (`C-u`, `ctrl-u`, `M-x`) is REFUSED, never translated by guess: * herdr would reject it as invalid_key, and a transport that "helpfully" rewrote it could * just as easily rewrite it wrong and type garbage into a pane. */ export const HERDR_KEYS: Readonly> = Object.freeze({ enter: "enter", escape: "esc", "clear-line": "ctrl+u", }); export function herdrKeyName(intentOrName: string): { ok: true; key: string } | { ok: false; error: string } { const s = String(intentOrName ?? "").trim(); if (/^(?:C|M|S)-/i.test(s) || /^(?:ctrl|alt|meta|shift)-/i.test(s)) { return { ok: false, error: `key '${s}' is tmux vocabulary and herdr rejects it as invalid_key — the herdr form is '${s.replace(/^(?:C|ctrl)-/i, "ctrl+").replace(/^(?:M|alt|meta)-/i, "alt+")}'; refused rather than guessed` }; } if (HERDR_KEYS[s]) return { ok: true, key: HERDR_KEYS[s] }; if (/^[a-z0-9]+(?:\+[a-z0-9]+)*$/.test(s)) return { ok: true, key: s }; return { ok: false, error: `key '${s}' is not a herdr key name (letters, digits and '+', e.g. ctrl+u) and not a known intent (${Object.keys(HERDR_KEYS).join(", ")})` }; } type PaneInfo = { pane_id?: string; agent_status?: string; agent?: string; workspace_id?: string }; type ProcessInfo = { foreground_processes?: { argv?: string[]; pid?: number; name?: string }[]; shell_pid?: number }; function paneOf(r: HerdrResult): PaneInfo | undefined { const j = r.json as { result?: { pane?: PaneInfo; root_pane?: PaneInfo } } | undefined; return j?.result?.pane ?? j?.result?.root_pane; } function processInfoOf(r: HerdrResult): ProcessInfo | undefined { return (r.json as { result?: { process_info?: ProcessInfo } } | undefined)?.result?.process_info; } const LIVE_STATUSES = new Set(["idle", "working", "blocked", "done"]); /** * ⟨q-15d763dc⟩ What a push did, so a caller can tell a message that is safe to try again from one * that may already be sitting in the pane: * delivered — verified: the ready box took it and the transcript shows it. The ONLY * outcome a cursor may advance on. * held — NO key was sent (no ready box, a draft, a menu, or the pane unreadable). * Safe to retry. * typed-unconfirmed — the text was typed but did not show in the box; Enter was NOT sent. * pending — the text is still in the box after every Enter. ⟨q-1ce5bc97⟩ step 1: this * is the MOST recoverable non-delivery there is — the payload is KNOWN to be * sitting, verified, in the target's own box, not lost and not ambiguous. * `safeToRetry: false` here turned a transient misread into a PERMANENT drop: * the tail's held-path (`herdr-tail.ts`) is what retries safely (it advances * nothing on a hold, and stops the source rather than typing past it), so * refusing that path was refusing the one recovery this outcome has. NOW true. * unverified — Enter was sent and the screen after it does not prove submission. */ export type PushOutcome = "delivered" | "held" | "typed-unconfirmed" | "pending" | "unverified"; export type HerdrPushResult = { delivered: boolean; verified?: boolean; outcome?: PushOutcome; safeToRetry?: boolean; error?: string; enters?: number; unguarded?: boolean; busy?: boolean }; export type HerdrTransportOptions = { run?: HerdrRunner; /** Milliseconds to wait before reading a pane back after typing. */ settleMs?: number; /** Sleep, injectable so tests do not wait. */ sleep?: (ms: number) => void; /** Lines of pane to read back when verifying a delivery. */ readLines?: number; /** Injectable marker-pid decision (tests); defaults to asking the kernel about pid 1. */ markerPid?: () => HerdrMarkerPid; /** * The environment `attach` reads its default pane from. Injectable because it is AMBIENT * PROCESS STATE, and ambient state is the one input an injected runner does not cover. * * ⛔ MEASURED 2026-09-16, the day seats moved onto herdr: the attach test injected a scripted * runner and still read the REAL `process.env.HERDR_PANE_ID`. Outside herdr the variable was * absent and the test passed; inside a herdr pane it resolved to the gater's own pane * (`wA6:p1`), the scripted runner had never heard of it, and the suite went red on every * herdr-hosted tree for a reason unrelated to any diff. Stripping `HERDR_PANE_ID` alone — one * variable at a time, six others left in place — was what turned it green. */ env?: Record; /** Override the submit mode this instance uses; defaults to `submitModeOf(this.#env)`. Tests only. */ submitMode?: SubmitMode; }; /** * ⟨q-f7e3c701⟩ — WHICH SUBMIT PATH `push` USES. `agent-prompt` is the atomic fix * (`herdr agent prompt `, one call, no type→read-back→Enter window). `send-keys` * is today's path (type, verify it landed, Enter, verify it submitted). * * DEFAULT IS `send-keys`, DELIBERATELY. The blast radius of switching is both fleets and the * failure mode is silence — a wrong default ships broken delivery to every seat with no signal * until someone notices nothing arrived. `agent-prompt` is enabled per-seat as a canary via * `AGENT_COORD_SUBMIT=agent-prompt`, compared against control seats by `outcome=` rate in * bus-daemon.err.log, and reverted by unsetting the variable — no republish required, which * matters because 0.26.28 took two days to travel merged→observed. */ export type SubmitMode = "agent-prompt" | "send-keys"; export const SUBMIT_MODE_ENV_VAR = "AGENT_COORD_SUBMIT"; export function submitModeOf(env: Record = process.env): SubmitMode { return env[SUBMIT_MODE_ENV_VAR] === "agent-prompt" ? "agent-prompt" : "send-keys"; } /** * ⟨q-abd88dd4⟩ — THE PID A HERDR MARKER CARRIES, chosen for the readers that CANNOT be patched. * * A herdr seat has no pusher, so its marker used to carry pid 0. Every server build from before * the herdr transport (0.19.1, kit 0.26.19, published 0.26.22 — reproduced in a throwaway bus * against each) decides marker liveness as `isPidAlive(marker.pid)` for everything but * `tmux-push-remote`, and `isPidAlive(0)` is false, so ONE ordinary read (`list_agents`, * `status`, `stall_check`, `send_command`) DELETES the marker. A mixed-build fleet deafens its * herdr seats as a side effect of looking at the roster. * * pid 1 is kept by all three: it always exists, so `isPidAlive(1)` is true. Their signal paths, * enumerated by qa across all three on its gate: kit 0.26.19 and published 0.26.22 gate * `detach_agent`'s SIGTERM on `isPusherProcess(marker.pid)`, which reads the process COMMAND, so they * never signal launchd whatever uid they run as. Only 0.19.1's detach is unguarded (`isPidAlive` * then `process.kill(marker.pid, "SIGTERM")`, reachable from `detach_agent`, `unregister` and * `rename_agent`), and it enforces identity, so its caller must be bound as that herdr seat. As a * non-root process that kill gets EPERM — measured `killed:false`, launchd untouched. The seat's own * server pid would have been kept by all three as well, and 0.19.1's unguarded detach would have * SIGTERMed that seat's MCP server; that shape is rejected. * * ⛔ SO pid 1 IS WRITTEN ONLY WHEN THIS PROCESS COULD NOT SIGNAL IT, asked of the kernel with * signal 0 rather than inferred from a uid: as root, or in a container where pid 1 is our own * user's process (often the server itself), `kill(1, 0)` succeeds and an old reader running * alongside could SIGTERM init. There the marker falls back to pid 0 and says why — an old * reader then deletes it, which is deafness, and deafness is recoverable where a signal to init * is not. RESIDUAL, stated as narrowly as it is: a 0.19.1-era server, running as ROOT, bound as * the herdr seat's OWN identity, calling detach, unregister or rename, reaches init. Nothing a * marker says can remove a privilege its reader holds. * * The CURRENT build never reads this pid as liveness: a herdr marker is live exactly when herdr * says its pane exists (`isMarkerLive`), and nothing signals it (`detach_agent` removes the * marker, `markerHoldsLiveProcess` answers false). */ export type HerdrMarkerPid = { pid: 0 | 1; why: string }; export function herdrMarkerPid(kill: (pid: number, signal: 0) => unknown = (p, s) => process.kill(p, s)): HerdrMarkerPid { try { kill(1, 0); 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" }; } catch (e) { const code = (e as NodeJS.ErrnoException).code; 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" }; return { pid: 0, why: `kill(1, 0) answered ${code ?? "an unexpected error"}, not EPERM — pid 1 not advertised` }; } } export class HerdrTransport implements Transport { readonly kind: TransportKind = HERDR; #run: HerdrRunner; #settleMs: number; #sleep: (ms: number) => void; #readLines: number; #markerPid: () => HerdrMarkerPid; #env: Record; #submitMode: SubmitMode; constructor(opts: HerdrTransportOptions = {}) { this.#run = opts.run ?? defaultHerdrRunner; // MEASURED on a shell pane in a task-owned workspace: after a single enter the output had // rendered by 400 ms and not by 150 ms — below that, render lag reads as an unsubmitted // line and the retry fires an empty enter (harmless, but counted). The genuine lost enter // the spike measured is Claude's input box; the retry exists for that, bounded to one. this.#settleMs = opts.settleMs ?? 400; this.#sleep = opts.sleep ?? ((ms) => { const end = Date.now() + ms; while (Date.now() < end) { /* spin: tiny and rare */ } }); this.#readLines = opts.readLines ?? 40; this.#markerPid = opts.markerPid ?? (() => herdrMarkerPid()); this.#env = opts.env ?? process.env; this.#submitMode = opts.submitMode ?? submitModeOf(this.#env); } /** Is herdr on this host AND is its server running? Both, or the reason. */ availability(): { available: boolean; reason: string } { const r = this.#run(["status"]); if (r.absent) return { available: false, reason: HERDR_ABSENT_MESSAGE }; if (!r.ok) return { available: false, reason: `herdr status failed: ${r.error?.message ?? r.stderr}` }; const running = /server:[\s\S]*status:\s*running/.test(r.stdout); return running ? { available: true, reason: `herdr server running (${(r.stdout.match(/version:\s*(\S+)/) ?? [])[1] ?? "version unread"})` } : { available: false, reason: `herdr binary present but its server is not running (herdr status: ${r.stdout.trim().split("\n").slice(-2).join(" ")}) — start herdr, or use the tmux-push transport` }; } available(): boolean { return this.availability().available; } /** * Attach = verify the pane and return a marker. No process is spawned: the marker's pid * is 0 and its `target` is the herdr pane id. Persisting the marker is the tool's job, * exactly as for tmux. */ async attach(args: { agentId: string; target?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number }): Promise { const avail = this.availability(); if (!avail.available) throw new Error(`herdr transport cannot attach '${args.agentId}': ${avail.reason}`); let target = args.target ?? this.#env.HERDR_PANE_ID; if (!target) { // ⟨q-e439e4ad⟩ `herdr pane current` asks HERDR FOR WHATEVER PANE IS CURRENTLY // FOCUSED ON THIS HOST — under stdio that is correct, because the server process // itself lives inside the caller's own pane (the same relationship $TMUX_PANE has // to a tmux server). AGENT_COORD_HTTP_PORT set means this process is instead the // shared bus DAEMON: one long-lived process with no pane of its own, answering // every seat on the host. "Current" there names whichever terminal a human last // focused — measured attaching two different seats onto ANOTHER FLEET's panes, // both calls returning `ok`. Same rule as transports/config.ts's unknown-transport // refusal: a silent fallback and a correct default produce identical evidence, so // this refuses instead of guessing. if (this.#env.AGENT_COORD_HTTP_PORT) { throw new Error( `herdr target not provided for '${args.agentId}': this server is the shared HTTP daemon ` + `(AGENT_COORD_HTTP_PORT set) and has no pane of its own, so \`herdr pane current\` would name ` + `whichever pane last had focus on this host, not '${args.agentId}''s. Pass target explicitly ` + `(e.g. 'w2:p1').`, ); } const cur = this.#run(["pane", "current"]); target = paneOf(cur)?.pane_id; } if (!target) { throw new Error("herdr target not provided and this process is not inside a herdr pane (no HERDR_PANE_ID, `herdr pane current` answered nothing). Pass target explicitly (e.g. 'w2:p1')."); } const got = this.#run(["pane", "get", target]); if (!got.ok) throw new Error(`herdr pane '${target}' not found: ${got.error?.message ?? got.stderr}`); const markerPid = this.#markerPid(); return { agentId: args.agentId, transport: HERDR, pid: markerPid.pid, pidWhy: markerPid.why, target, tmuxTarget: target, since: Date.now(), rooms: args.includeRoom !== false, }; } /** Does the pane exist, per herdr, through THIS transport's runner? null = could not ask. */ paneExists(target: string): boolean | null { return herdrPaneExists(target, this.#run); } /** Nothing to kill: there is no pusher. The tool deletes the marker. */ async detach(_agentId: string): Promise { return; } /** * Type `text` into the pane and press enter; VERIFY by reading the pane back, and if * the line is still sitting unsubmitted (the spike's race), press enter once more. * Reports the enters it took so the race is measured on every delivery. * * ⟨q-dc83023a⟩ A DELIVERY IS A BRACKETED PASTE BY DEFAULT. `send-text` types keystrokes and * herdr writes them in 1022-byte chunks; Claude Code v2.1.273 folds each large chunk into a * `[Pasted text #N]` chip and, on submit, KEPT ONLY THE LAST CHUNK — a 2668-byte message * arrived as its final 624 bytes, the PR, sha and gate gone. Wrapped in paste markers, the * same bytes arrived whole (measured at 2668 B, 5 KB multi-line and 20 KB). The body is * neutralised first so it can never carry the closing marker itself (⟨q-e5cb3538⟩). * `paste: false` is for control commands only: a slash command must arrive as typing. */ async push(marker: TransportMarker, text: string, opts: { paste?: boolean } = {}): Promise { const avail = this.availability(); if (!avail.available) return { delivered: false, outcome: "held", safeToRetry: true, error: avail.reason }; const target = targetOf(marker); if (!target) return { delivered: false, outcome: "held", safeToRetry: true, error: "no target recorded on the marker" }; // ⟨q-f7e3c701⟩ ATOMIC SUBMIT, canaried per seat via AGENT_COORD_SUBMIT. Everything below this // branch is the send-keys path (today's default); #pushAgentPrompt is the opt-in replacement. if (this.#submitMode === "agent-prompt") return this.#pushAgentPrompt(target, text); const payload = opts.paste === false ? text : `${PASTE_START}${neutralizeControls(text)}${PASTE_END}`; const enterKey = herdrKeyName("enter"); if (!enterKey.ok) return { delivered: false, outcome: "held", safeToRetry: true, error: enterKey.error }; if (readyProfile().profile === "none") return this.#pushUnguarded(target, text, payload, enterKey.key); // ⟨q-15d763dc⟩ BEFORE ANY KEY: the screen must be Claude Code's ready, empty input box. The text // is held as firmly as the Enter — a digit typed into an open dialog selects an option. const before = readReadyBox(this.#screen(target)); const refusal = refusalBeforeKeys(before); if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` }; const typed = this.#run(["pane", "send-text", target, payload]); if (!typed.ok) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` }; this.#sleep(this.#settleMs); let box = readReadyBox(this.#screen(target)); if (!boxHoldsPayload(box, text)) { 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` }; } let enters = 0; for (let attempt = 0; attempt < 2; attempt++) { // Every Enter follows a read that saw THIS payload in the ready box, never a stale one. const pressed = this.#run(["pane", "send-keys", target, enterKey.key]); 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 }; enters++; this.#sleep(this.#settleMs); box = readReadyBox(this.#screen(target)); if (transcriptGained(before, box, text)) return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false, enters, busy: box.ready ? box.busy ?? undefined : undefined }; if (boxHoldsPayload(box, text)) continue; 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})` }; } return { delivered: false, verified: true, outcome: "pending", safeToRetry: true, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` }; } /** * ⟨q-f7e3c701⟩ ATOMIC SUBMIT, behind `AGENT_COORD_SUBMIT=agent-prompt` (default `send-keys` — * see `submitModeOf`). `herdr agent prompt --wait --until working` types and * submits in ONE call: no type→read-back→Enter window for a decoration to land in, which is * the class the send-keys path above exists to survive. MEASURED (joint cross-fleet design, * 2026-09-21): 4/4 across two hosts, including a LIVE WEDGE the send-keys path had already * abandoned in the same minute. * * ⛔ `refusalBeforeKeys` IS NOT OPTIONAL — MEASURED ON A THROWAWAY PANE: `agent prompt` into a * DIRTY composer returned `agent_prompted` (success) and CONCATENATED the stale draft with the * new text, no separator, then submitted both. Atomic submit alone is strictly WORSE than * today: a recoverable `held` becomes unrecoverable corruption carrying a success receipt. * `agent_blocked` is a STATUS axis and is BLIND to a dirty composer (reads * `status: idle · interactive_ready: true` regardless), so it cannot substitute for reading * the box first — this method still reads it, exactly as the send-keys path does. * * Receipt is `--until working`, an OBSERVED state transition — strictly stronger than "I typed * it and could read it back", the only receipt class send-keys has ever had. A stall/timeout is * treated as UNCONFIRMED, never as safe to retry: unlike send-keys's `typed-unconfirmed` (where * nothing had actually been typed yet), a stalled atomic submit may already have gone through, * and retrying risks a genuine double-send. * * ⛔⛔ THE FIELD PATH BELOW WAS WRONG ONCE, SHIPPED, AND CAUGHT BY QA'S OWN LIVE SMOKE TEST * BEFORE MERGE — recorded because the wrong version passed every unit test, having been * written against a GUESSED shape (`json.status ?? json.result.status`) that the mocks then * reproduced instead of measuring. Against the real CLI that path is always `undefined`, so * every genuine success fell through to the catch-all and the feature would have reported * 100% failure on real hardware. VERIFIED PAYLOADS, captured against herdr 0.9.1 by the aide * and independently reproduced here on a disposable scratch pane (`herdr workspace create` / * `agent start --kind claude` / closed after): * success: {"result":{"agent":{...},"type":"agent_prompted"}} — result.type, not result.status * blocked: {"error":{"code":"agent_blocked","message":"..."}} — TOP-LEVEL error, no `result` at all * timeout: {"error":{"code":"timeout","message":"..."}} — same top-level shape as blocked * So this is TWO PATHS, not one field read: `result.type` for success, `error.code` for every * refusal. A single-location parse mishandles one of them regardless of which is picked — the * exact shape of the bug that shipped. `agent_prompt_stalled` was NOT reproduced against a real * binary despite attempting it (the 5000ms stall window did not fire in either kit's or my own * attempts) — it is handled via the same `error.code` path as `agent_blocked`/`timeout` BY * ANALOGY with the CLI's consistent refuse-via-top-level-error convention, not by measurement. * If that analogy is wrong, the failure mode is `unverified/safeToRetry:false` (never a * double-send) rather than a false "delivered", so the untested case fails safe. */ async #pushAgentPrompt(target: string, text: string): Promise { if (readyProfile().profile !== "none") { const before = readReadyBox(this.#screen(target)); const refusal = refusalBeforeKeys(before); if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` }; } const r = this.#run(["agent", "prompt", target, text, "--wait", "--until", "working"]); const resultType = (r.json as { result?: { type?: string } } | undefined)?.result?.type; if (r.ok && resultType === "agent_prompted") { return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false }; } if (r.error?.code === "agent_blocked") { // MEASURED: nothing is typed into the pane on agent_blocked — the agent itself refused the // prompt before any input was sent. Safe to retry, same as a send-keys `held`. return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `agent_blocked at ${target}: ${r.error.message}` }; } if (r.error?.code === "agent_prompt_stalled" || r.error?.code === "timeout") { return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, error: `${r.error.code} at ${target}: submission unconfirmed by --wait --until working — not retried, to avoid a double-send on an atomic path`, }; } return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, error: `agent prompt to ${target} refused or unrecognised: ${r.error?.message ?? r.stderr ?? JSON.stringify(r.json)}` }; } /** The screen, styled, so the ready-box reader can tell a ghost suggestion from a draft. null = unreadable. */ #screen(target: string): string | null { const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "ansi"]); return read.ok ? read.stdout : null; } /** * AGENT_COORD_READY_PROFILE=none, chosen by whoever started this process: #357's push, with no * readiness check. Reported `unguarded`, and an unreadable screen still never counts as verified. */ #pushUnguarded(target: string, text: string, payload: string, enter: string): HerdrPushResult { const typed = this.#run(["pane", "send-text", target, payload]); if (!typed.ok) return { delivered: false, unguarded: true, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` }; let enters = 0; for (let attempt = 0; attempt < 2; attempt++) { const pressed = this.#run(["pane", "send-keys", target, enter]); 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 }; enters++; this.#sleep(this.#settleMs); const pending = this.#stillPending(target, text); if (pending === false) return { delivered: true, verified: true, unguarded: true, outcome: "delivered", safeToRetry: false, enters }; 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` }; } return { delivered: false, verified: true, unguarded: true, outcome: "pending", safeToRetry: true, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` }; } /** * Read the pane back: is the typed text still sitting in the INPUT (unsubmitted)? * true = pending · false = submitted · null = could not read (unverified, not failed). * * ⟨q-dc83023a⟩ THE INPUT IS THE PROMPT LINE, NOT THE LAST LINE. Claude Code draws a border, * a footer and hints ("paste again to expand") BELOW its `❯` input, so the last non-empty * line is never the input and the old last-line check answered "submitted" for a message * still sitting there. The prompt line is pending when it shows a paste chip or the start * of what was typed (a long first line wraps, so either may be a prefix of the other). * * NO PROMPT LINE → null (UNVERIFIED), never the last-line reading: that reading was measured * to answer "submitted" for text still sitting in Claude's input, so falling back to it would * let a changed Claude version, an open dialog or a read mid-render verify silently again * (the coordinator's condition on ⟨q-dc83023a⟩, from worker-2). What a caller does with an * unverified delivery is ⟨q-15d763dc⟩. */ #stillPending(target: string, text: string): boolean | null { const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "text"]); if (!read.ok) return null; const lines = read.stdout.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim().length > 0); const firstLine = text.split("\n")[0]; const prompt = [...lines].reverse().find((l) => /^\s*❯/.test(l)); if (prompt !== undefined) { const content = prompt.replace(/^\s*❯\s?/, "").trim(); if (!content) return false; if (PASTE_CHIP_RE.test(content)) return true; return firstLine.startsWith(content) || content.startsWith(firstLine); } return null; } /** * THREE ANSWERS FROM HERDR'S OWN STATUS, never a pid heuristic: * · not a herdr marker / no target / herdr unavailable → unknown, naming which * · pane_not_found → dead * · agent_status idle | working | blocked | done → live (blocked IS live: it waits on a person) * · agent_status unknown, a foreground process present → unknown, naming the process (the wedged shape) * · agent_status unknown, nothing in the foreground → unknown (a shell pane nobody is in) */ async probe(marker: TransportMarker): Promise { if (marker.transport !== HERDR) return { state: "unknown", reason: `transport "${marker.transport}" is not herdr` }; const target = targetOf(marker); if (!target) return { state: "unknown", reason: "no target recorded on the marker" }; const avail = this.availability(); if (!avail.available) return { state: "unknown", reason: avail.reason }; const got = this.#run(["pane", "get", target]); if (!got.ok) { if (got.error?.code === "pane_not_found") return { state: "dead", reason: `herdr reports pane ${target} not found (${got.error.message})` }; return { state: "unknown", reason: `herdr could not describe pane ${target}: ${got.error?.message ?? got.stderr}` }; } const status = String(paneOf(got)?.agent_status ?? "unknown"); if (LIVE_STATUSES.has(status)) return { state: "live" }; const proc = this.#run(["pane", "process-info", "--pane", target]); const fg = processInfoOf(proc)?.foreground_processes ?? []; if (fg.length) { const p = fg[0]; return { state: "unknown", reason: `herdr reports agent_status "${status}" for pane ${target}; foreground process ${JSON.stringify(p.argv ?? [p.name])} (pid ${p.pid}) — present but not a recognised agent` }; } return { state: "unknown", reason: `herdr reports agent_status "${status}" for pane ${target} and no foreground process` }; } /** * A control command is typed as `/` and verified to have LEFT the input, with the * same enter-race handling as push. All three commands ride the path the spike measured * for /clear; compact and reload-skills are the same delivery path (spike: inferred, * not measured) and are said so in the result. */ async sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string; enters?: number; note?: string }> { const r = await this.push(marker, `/${cmd}`, { paste: false }); // push reports delivered only when verified (⟨q-15d763dc⟩), so `delivered` is the whole test here. if (!r.delivered) return { ok: false, error: r.error ?? "control not delivered", enters: r.enters }; return { ok: true, enters: r.enters, note: cmd === "clear" ? "measured end to end in the spike" : `same delivery path as /clear; ${cmd} itself was inferred, not measured, by the spike`, }; } /** * ⟨q-1c95f7d4⟩ 5.1 — THE EXTERNAL TICK, CONSUMED. herdr's own `agent_status` for the * pane, which is the signal this fleet has never had: our liveness is pid-existence and * our activity is VCS commits, so a THINKING lane and a WEDGED lane are identical to us. * * ⛔ `unknown` AND `done` ARE NOT SEAT STATES AND MUST NOT READ AS CALM. `unknown` is * herdr saying it has no agent there (the shape a plain shell pane returns, measured); * `done` is a finished session, not a moving seat. Both come back UNREADABLE, so the * caller counts them as unmeasured rather than crediting coverage — the exact inversion * (blind read as quiet) that `stall_check` shipped and this task exists to end. */ async readTick(marker: TransportMarker): Promise { if (marker.transport !== HERDR) return { readable: false, why: `transport "${marker.transport}" has no external tick — only a herdr marker does` }; const target = targetOf(marker); if (!target) return { readable: false, why: "no target recorded on the marker" }; const avail = this.availability(); if (!avail.available) return { readable: false, why: avail.reason }; const got = this.#run(["pane", "get", target]); if (!got.ok) { 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)` }; return { readable: false, why: `herdr could not describe pane ${target}: ${got.error?.message ?? got.stderr}` }; } const status = String(paneOf(got)?.agent_status ?? "unknown"); const state = TICK_READS_AS[status]; if (state) return { readable: true, state, source: `herdr pane ${target}` }; 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` }; } /** * ⟨q-927e28cd⟩ THE SCREEN, FOR A CONSUMER — herdr's own `pane read`, the same read `push` * verifies with. Same guards, in the same order, as `readTick`: a marker that is not herdr's, * one with no target, or herdr absent answers UNKNOWN naming which; a pane herdr cannot find * is UNKNOWN too — it is gone, and a gone pane has no screen, not an empty one. */ async readPane(marker: TransportMarker, opts: { lines?: number; format?: "text" | "ansi" } = {}): Promise { return this.readPaneNow(marker, opts); } /** The whole rule, synchronous — `readPane` and the capabilities probe both call THIS, so the probe cannot pass while the verb behaves differently. */ readPaneNow(marker: TransportMarker, opts: { lines?: number; format?: "text" | "ansi" } = {}): PaneRead { if (marker.transport !== HERDR) return { state: "unknown", why: `transport "${marker.transport}" is not herdr — this transport reads only its own panes` }; const target = targetOf(marker); if (!target) return { state: "unknown", why: "no target recorded on the marker" }; const avail = this.availability(); if (!avail.available) return { state: "unknown", why: avail.reason }; const lines = Math.min(Math.max(Math.trunc(opts.lines ?? 200), 1), 2000); const format = opts.format === "ansi" ? "ansi" : "text"; const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(lines), "--format", format]); if (!read.ok) { if (read.error?.code === "pane_not_found") return { state: "unknown", why: `herdr reports pane ${target} not found — a gone pane has no screen to read` }; return { state: "unknown", why: `herdr could not read pane ${target}: ${read.error?.message ?? read.stderr}` }; } return { state: "read", text: read.stdout, lines, format, source: `herdr pane ${target}` }; } /** * ⟨q-1c95f7d4⟩ Task 1's open question — PUBLISH, and it needs a receipt it does not get. * * MEASURED 2026-09-15 in a workspace of my own: `herdr pane report-agent --source * coord-mcp --agent