tickmarkr 1.83.0 → 1.85.0

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 (37) hide show
  1. package/dist/adapters/claude-code.d.ts +1 -0
  2. package/dist/adapters/claude-code.js +57 -1
  3. package/dist/adapters/fake.js +9 -0
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/adapters/types.js +21 -0
  6. package/dist/cli/commands/status.js +160 -30
  7. package/dist/compile/collateral.d.ts +86 -2
  8. package/dist/compile/collateral.js +294 -3
  9. package/dist/config/config.d.ts +62 -0
  10. package/dist/config/config.js +157 -2
  11. package/dist/drivers/herdr.d.ts +20 -3
  12. package/dist/drivers/herdr.js +288 -105
  13. package/dist/gates/baseline.d.ts +1 -0
  14. package/dist/gates/baseline.js +91 -13
  15. package/dist/gates/review.d.ts +7 -0
  16. package/dist/gates/review.js +99 -6
  17. package/dist/gates/run-gates.d.ts +9 -0
  18. package/dist/gates/run-gates.js +285 -41
  19. package/dist/run/daemon.d.ts +48 -2
  20. package/dist/run/daemon.js +1417 -315
  21. package/dist/run/journal.d.ts +56 -3
  22. package/dist/run/journal.js +275 -1
  23. package/dist/run/stall.d.ts +35 -1
  24. package/dist/run/stall.js +118 -8
  25. package/dist/tui/cockpit/components.d.ts +30 -1
  26. package/dist/tui/cockpit/components.js +19 -3
  27. package/dist/tui/cockpit/derive.d.ts +29 -2
  28. package/dist/tui/cockpit/derive.js +219 -23
  29. package/dist/tui/cockpit/layout.d.ts +75 -6
  30. package/dist/tui/cockpit/layout.js +97 -19
  31. package/dist/tui/cockpit/live.d.ts +14 -1
  32. package/dist/tui/cockpit/live.js +223 -29
  33. package/dist/tui/cockpit/pointer.d.ts +261 -0
  34. package/dist/tui/cockpit/pointer.js +610 -0
  35. package/dist/tui/cockpit/run-cockpit.d.ts +36 -5
  36. package/dist/tui/cockpit/run-cockpit.js +270 -51
  37. package/package.json +1 -1
@@ -1,6 +1,11 @@
1
- import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, shq } from "../adapters/types.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, matchesOccupiedInputBox, missingInputStateDeclarations, shq } from "../adapters/types.js";
2
6
  import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
3
7
  import { createWorktree, sh } from "../run/git.js";
8
+ import { Journal } from "../run/journal.js";
4
9
  import { herdrSealShellPrefix } from "./subprocess.js";
5
10
  import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
6
11
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
@@ -14,6 +19,15 @@ const DELIVERY_READ_LINES = 80;
14
19
  const DELIVERY_SETTLE_READ_ATTEMPTS = 6;
15
20
  const DELIVERY_SETTLE_POLL_MS = 100;
16
21
  const DELIVERY_READINESS_TIMEOUT_MS = 1_000;
22
+ // OBS-140/253: a shell or bootstrap dispatch is acknowledged by a START nonce the delivered line
23
+ // PRINTS as its first statement. The printf splits the nonce across two arguments, so the joined
24
+ // marker exists only after the shell actually ran the line — a pane that merely echoed the text it
25
+ // was given never produces it. The marker is written to a private file as well as the pane, because
26
+ // a pane snapshot is not a channel: a full-screen TUI or a noisy launch can scroll a printed nonce
27
+ // out of any bounded read, and a successful dispatch read as corrupt is dispatched TWICE.
28
+ export const DISPATCH_START_PREFIX = "TICKMARKR_START_";
29
+ const DISPATCH_ACK_TIMEOUT_MS = 15_000;
30
+ const DISPATCH_ACK_POLL_MS = 100;
17
31
  const SYSTEM_TIME = {
18
32
  now: () => Date.now(),
19
33
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -32,6 +46,21 @@ export class DeliveryReadinessError extends Error {
32
46
  this.name = "DeliveryReadinessError";
33
47
  }
34
48
  }
49
+ // OBS-253: the dispatch-corruption class, typed so the driver's own one-shot recovery can recognise
50
+ // it and the daemon can keep classifying an unrecovered one as `kind: dispatch`. A dispatch that
51
+ // never registered produced no output to trust, so a fresh-pane retry risks nothing a first dispatch
52
+ // does not already risk — the second consecutive one is what parks the task.
53
+ export class DeliveryCorruptedError extends Error {
54
+ pane;
55
+ transcript;
56
+ phase = "DISPATCH";
57
+ constructor(pane, reason, transcript) {
58
+ super(`herdr delivery corrupted on pane ${pane} — ${reason} (OBS-140/253); pane transcript:\n${transcript}`);
59
+ this.pane = pane;
60
+ this.transcript = transcript;
61
+ this.name = "DeliveryCorruptedError";
62
+ }
63
+ }
35
64
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
36
65
  export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
37
66
  if (paneCols == null || paneCols <= 0)
@@ -42,6 +71,7 @@ export class HerdrDriver {
42
71
  bin;
43
72
  workersPerTab;
44
73
  time;
74
+ journal;
45
75
  id = "herdr";
46
76
  interactive = true;
47
77
  groups = new Map();
@@ -54,16 +84,44 @@ export class HerdrDriver {
54
84
  dispatchLeases = new WeakMap();
55
85
  deliveredPanes = new WeakMap();
56
86
  inputBoxes = new WeakMap();
87
+ // OBS-253: the adapter-declared bootstrap this slot has already delivered. A fresh pane is a bare
88
+ // shell, so it is the only thing that can put a TUI back under a typed turn that has to move.
89
+ bootstraps = new WeakMap();
90
+ // runDaemon gives the driver the authoritative repo root at its worktree seam before it allocates
91
+ // that task's slot. Keep the binding by cwd so a dispatch retry opens THAT run's Journal even when
92
+ // the caller launched tickmarkr elsewhere (process.cwd is not run identity). The repo itself is
93
+ // also bound for judge/review/consult slots whose cwd is the root rather than a task worktree.
94
+ journalRoots = new Map();
57
95
  // VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
58
96
  // operator's env before the driver is built). Required at slot() time, never in the constructor —
59
97
  // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
60
98
  ws = process.env.HERDR_WORKSPACE_ID;
61
99
  callerPane = process.env.HERDR_PANE_ID;
62
100
  watches = new Map();
63
- constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME) {
101
+ constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME, journal) {
64
102
  this.bin = bin;
65
103
  this.workersPerTab = workersPerTab;
66
104
  this.time = time;
105
+ this.journal = journal;
106
+ }
107
+ // The dispatch-retry record is mandatory: it is the only durable fact left by a recovered pane
108
+ // swap. Tests may inject a sink, while production resolves the daemon's real Journal from the
109
+ // worktree binding plus the canonical slot name. Every resolution/open/append failure propagates;
110
+ // an unjournaled recovery is never performed and its audit failure is never hidden by the original
111
+ // corruption error.
112
+ appendDispatchRetry(slot, data) {
113
+ if (this.journal) {
114
+ this.journal("dispatch-retry", slot.name, data);
115
+ return;
116
+ }
117
+ const owned = parseOwnedName(slot.name);
118
+ if (!owned)
119
+ throw new Error(`cannot journal dispatch-retry: slot ${slot.name} carries no run identity`);
120
+ const repoRoot = this.journalRoots.get(slot.cwd);
121
+ if (!repoRoot) {
122
+ throw new Error(`cannot journal dispatch-retry: slot ${slot.name} has no daemon repo binding for ${slot.cwd}`);
123
+ }
124
+ Journal.open(repoRoot, owned.runId).append("dispatch-retry", owned.taskId, data);
67
125
  }
68
126
  serial(fn) {
69
127
  const p = this.groupSerial.then(fn, fn);
@@ -124,62 +182,18 @@ export class HerdrDriver {
124
182
  const needle = norm(cmd);
125
183
  return needle.length > 0 && hay.includes(needle);
126
184
  }
127
- shellExecutionEchoed(transcript, cmd) {
128
- const norm = (s) => s.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\s+/g, " ").trim();
129
- const needle = norm(cmd);
130
- if (!needle)
131
- return false;
132
- return transcript.split("\n").some((rawLine) => {
133
- const line = norm(rawLine);
134
- if (line === needle || !line.endsWith(needle))
135
- return false;
136
- const prefix = line.slice(0, -needle.length).trimEnd();
137
- if (/[│┃╭╰┌└]/.test(prefix))
138
- return false;
139
- return /^(?:[$%#>]|[➜❯❱›»λ])(?:\s|$)/.test(prefix)
140
- || /[$%#>✗❯❱›»λ]$/.test(prefix);
141
- });
142
- }
143
- // Submission requires positive post-Enter evidence. A prompt echoed above a fresh input target
144
- // proves registration; a declared box that is visibly empty after the verified paste also proves
145
- // it consumed the prompt. At the launch stage, a structurally prompt-prefixed shell execution echo
146
- // is stronger evidence than waiting for first paint from the launched interface (OBS-144).
147
- // Prompt absence alone is never success (OBS-142).
148
- submissionRegistered(transcript, cmd, inputBox) {
149
- const norm = (s) => s.replace(/\s+/g, "");
150
- const hay = norm(transcript);
151
- const needle = norm(cmd);
152
- const promptAt = hay.lastIndexOf(needle);
153
- if (needle.length === 0)
185
+ // v1.85 T5: submission is acknowledged CAUSALLY or not at all. The adapter's declared OCCUPIED
186
+ // state means the prompt is still sitting in the box — never submitted; its declared EMPTY state
187
+ // means the box consumed it. Nothing else counts: the positional-transcript fallback this used to
188
+ // end with (prompt found, bytes exist after it ⇒ "delivered") answered yes for panes that had
189
+ // processed nothing (OBS-181), and it is DELETED. A box that is off-screen or unrecognisable is an
190
+ // absence of evidence, which fails closed — the same posture every gate takes. The launch-stage
191
+ // shell-echo reader is gone with it: a bootstrap line is now delivered atomically and acknowledged
192
+ // by its own START nonce, so there is no echo left to read (OBS-144 closed by construction).
193
+ submissionRegistered(transcript, inputBox) {
194
+ if (matchesOccupiedInputBox(transcript, inputBox))
154
195
  return false;
155
- if (inputBox?.launchCommand?.(cmd) === true && this.shellExecutionEchoed(transcript, cmd))
156
- return true;
157
- if (promptAt < 0)
158
- return inputBox !== undefined && matchesEmptyInputBox(transcript, inputBox);
159
- if (inputBox) {
160
- // OBS-181: an EMPTY declared input box is positive evidence of submission even while the
161
- // prompt is still visible above as the echoed user turn — which is exactly how every TUI
162
- // renders the moment after Enter lands. Test it FIRST: `match` means "the box is painted" and
163
- // is deliberately true for an empty box, so asking `match` first cannot decide submission.
164
- if (matchesEmptyInputBox(transcript, inputBox))
165
- return true;
166
- // The box is painted and is NOT empty: the prompt is still sitting in it. Only a fingerprint
167
- // appearing AFTER the prompt — a fresh input line below the submitted text — counts.
168
- //
169
- // This is the branch a wedged kimi pane belongs in, and could not reach: its occupied editor
170
- // renders a blank continuation row, the matcher demanded the bottom border immediately below
171
- // the prompt, so NEITHER matcher fired and the positional fallback below answered "delivered"
172
- // for a pane that had processed nothing. The matcher fix (adapters/kimi.ts) is what routes it
173
- // here; keeping the fallback unreachable for a painted box is what stops the next one.
174
- if (matchesInputBox(transcript, inputBox)) {
175
- return hay.lastIndexOf(norm(inputBox.fingerprint)) > promptAt;
176
- }
177
- }
178
- // No declared box, or the box is not on screen at all (it may have scrolled out of the read
179
- // window). Positional evidence only — weaker, and unsound for any surface that paints chrome
180
- // below its prompt, which is why a declared box must be able to recognise its own OCCUPIED
181
- // state. Every adapter declaring an inputBox needs a captured occupied frame in its tests.
182
- return promptAt + needle.length < hay.length;
196
+ return matchesEmptyInputBox(transcript, inputBox);
183
197
  }
184
198
  static available() {
185
199
  return process.env.HERDR_ENV === "1";
@@ -285,8 +299,6 @@ export class HerdrDriver {
285
299
  throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
286
300
  if (typeof id !== "string" || !id)
287
301
  throw new Error(`herdr tab create returned no root pane id (refusing untargeted placement): ${t.stdout}`);
288
- // T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
289
- const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
290
302
  // durable identity: label the PANE (resolution + reconcile read it back via `pane list`); fail closed
291
303
  // and reap the tab we just made if the rename fails (no orphan tab).
292
304
  const rn = await this.herdr(`pane rename ${shq(id)} ${shq(name)}`);
@@ -303,7 +315,7 @@ export class HerdrDriver {
303
315
  // not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
304
316
  // v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
305
317
  // the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
306
- const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
318
+ const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(name, "")))}; ${herdrSealShellPrefix()}`)}`);
307
319
  if (seed.code !== 0)
308
320
  throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
309
321
  return { id, name, cwd, tabId };
@@ -450,22 +462,18 @@ export class HerdrDriver {
450
462
  await this.renameGroupTab(entry);
451
463
  return { id: pane, name, cwd, tabId: entry.tabId, group };
452
464
  }
453
- // OBS-85 verified delivery: a pane paste can interleave a long dispatch line with itself (codex
454
- // `$(git rev-parse…)` mashed into its trailing printf — v1.58 T2 attempts 2-4, v1.61 T10). Never
455
- // the atomic `pane run` (text+Enter in one request, uninspectable between the two): type WITHOUT
456
- // enter, read the pane back — `wait output --match` checks the same unwrapped transcript pane
457
- // read exposes, event-driven so wrap and render timing can't race the check — and press Enter
458
- // only when that read-back contains the typed command. A corrupted paste is captured (pane read),
459
- // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
460
- // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
465
+ // v1.85 T5 (OBS-140/253): every dispatch is routed by what the target actually IS. A shell or
466
+ // bootstrap line goes out atomically through `pane run` and is acknowledged by its own START
467
+ // nonce; only a real TUI turn — an adapter-declared input box, and not that adapter's own launch
468
+ // command — earns the typed pincer. One dispatch corruption is then retried in-process against a
469
+ // FRESH pane before anything is reported as a failure.
461
470
  async run(slot, cmd) {
462
471
  const lease = this.dispatchLeases.get(slot);
463
472
  if (lease) {
464
473
  this.dispatchLeases.delete(slot);
465
474
  try {
466
475
  const paneId = await this.verifyPaneIdentityBinding(slot);
467
- await this.deliver(slot, cmd, paneId);
468
- this.deliveredPanes.set(slot, paneId);
476
+ await this.dispatchWithRetry(slot, cmd, paneId);
469
477
  }
470
478
  finally {
471
479
  lease.release();
@@ -473,13 +481,195 @@ export class HerdrDriver {
473
481
  return;
474
482
  }
475
483
  return this.deliveryQueue(async () => {
476
- const paneId = await this.paneId(slot);
477
- await this.deliver(slot, cmd, paneId);
478
- this.deliveredPanes.set(slot, paneId);
484
+ await this.dispatchWithRetry(slot, cmd, await this.paneId(slot));
479
485
  });
480
486
  }
481
- async deliver(slot, cmd, pane, verifySubmission = true) {
482
- const readiness = await this.awaitDeliveryReadiness(slot, cmd, pane);
487
+ // OBS-253: dispatch corruption is self-clearing — all ten recorded occurrences cleared on a fresh
488
+ // pane and a fresh submit, and every one of them cost an operator `resume --retry-failed`. Retry
489
+ // ONCE here against a pane that has never seen this delivery; the wedged pane is closed, never
490
+ // re-pressed. A second consecutive corruption propagates, and the daemon parks the task on it.
491
+ async dispatchWithRetry(slot, cmd, pane) {
492
+ try {
493
+ await this.dispatch(slot, cmd, pane);
494
+ this.deliveredPanes.set(slot, pane);
495
+ return;
496
+ }
497
+ catch (error) {
498
+ if (!(error instanceof DeliveryCorruptedError))
499
+ throw error;
500
+ // A fresh pane is a bare shell. A shell dispatch is simply re-run on it; a typed turn first
501
+ // needs its interface back, which is possible only where the adapter declared the bootstrap
502
+ // this slot already delivered. Retyping a turn into a bare shell would guarantee the second
503
+ // failure rather than recover from the first, so without a replayable bootstrap the
504
+ // corruption propagates untouched.
505
+ const typedTurn = this.isTuiTurn(slot, cmd);
506
+ const relaunch = typedTurn ? this.bootstraps.get(slot) : undefined;
507
+ if (typedTurn && relaunch === undefined)
508
+ throw error;
509
+ // Record BEFORE acting: a retry that cannot be written to the run's ledger is not taken at all,
510
+ // because an unrecorded pane swap is exactly the silence OBS-253 cost ten times. The journal
511
+ // failure itself propagates visibly; the original corruption then remains terminal rather than
512
+ // triggering an off-record recovery.
513
+ this.appendDispatchRetry(slot, { wedgedPane: pane, reason: error.message });
514
+ const fresh = await this.freshPane(slot, pane);
515
+ if (fresh === null)
516
+ throw error;
517
+ if (relaunch !== undefined)
518
+ await this.deliverAtomic(slot, relaunch, fresh);
519
+ await this.dispatch(slot, cmd, fresh);
520
+ this.deliveredPanes.set(slot, fresh);
521
+ }
522
+ }
523
+ // Is this delivery a turn TYPED into a running interface, rather than a line a shell runs? The
524
+ // adapter's declaration decides it wherever there is one: `launchCommand` names the bootstrap,
525
+ // everything else is a turn. With NO declaration the only evidence is the slot's own history — a
526
+ // pane that already accepted a delivery is running whatever that delivery started, so a second
527
+ // delivery is a turn into it. Answering "shell" there is what would run an adapter's seed prompt
528
+ // as a shell command; answering "turn" routes it to deliverTyped, which refuses it by name
529
+ // because the adapter declared no input box (fail closed, never a guess).
530
+ isTuiTurn(slot, cmd) {
531
+ const inputBox = this.inputBoxes.get(slot);
532
+ return inputBox ? inputBox.launchCommand?.(cmd) !== true : this.deliveredPanes.has(slot);
533
+ }
534
+ dispatch(slot, cmd, pane) {
535
+ if (this.isTuiTurn(slot, cmd))
536
+ return this.deliverTyped(slot, cmd, pane);
537
+ // An adapter-declared bootstrap is the one command that can restore this slot's interface on a
538
+ // fresh pane, so remember it for the recovery above.
539
+ if (this.inputBoxes.get(slot)?.launchCommand?.(cmd) === true)
540
+ this.bootstraps.set(slot, cmd);
541
+ return this.deliverAtomic(slot, cmd, pane);
542
+ }
543
+ // The OBS-140 class dies here. `pane run` puts text and Enter in ONE herdr request, so there is no
544
+ // seam in which a swallowed Enter can leave a verified-but-unsubmitted prompt, and nothing about
545
+ // the delivery is inferred from typed-text read-back. The line's first two statements print a
546
+ // per-dispatch START nonce assembled from two printf arguments — once to a file whose name only
547
+ // this process knows, then once to the pane for the operator. The joined marker cannot exist
548
+ // unless the shell RAN our line, so the acknowledgment is causal; and because the file is written
549
+ // before the agent the line launches produces a byte, no amount of later output can hide it. Both
550
+ // statements are shell builtins: the ack cannot fail for want of a binary on the pane's PATH.
551
+ //
552
+ // v1.85 T5 review: the durable half GATES the launch, and goes FIRST. A dispatch whose ack the
553
+ // pane could not write is indistinguishable, from here, from a line that never ran — so if the
554
+ // command ran anyway, the miss buys a fresh pane and a SECOND live agent for the same task. The
555
+ // `|| exit 1` makes that impossible: no ack, no command. Ordering carries the same weight in the
556
+ // other direction — a pane-visible marker printed before a failed ack write would satisfy the
557
+ // event watch and report a launch that never happened.
558
+ async deliverAtomic(slot, cmd, pane) {
559
+ const suffix = randomUUID(); // unguessable: nothing the delivered command runs can forge the ack
560
+ const nonce = `${DISPATCH_START_PREFIX}${suffix}`;
561
+ const ackPath = join(tmpdir(), `tickmarkr-dispatch-${suffix}.ack`);
562
+ // Open the channel before spending a pane on it: an ack path this process cannot write is a
563
+ // broken host, not a wedged pane, and the fresh-pane retry would fail there for the same reason.
564
+ // Throwing something other than DeliveryCorruptedError is what keeps that retry unspent.
565
+ try {
566
+ writeFileSync(ackPath, ""); // empty: only the delivered line may ever put the nonce here
567
+ }
568
+ catch (error) {
569
+ throw new Error(`dispatch acknowledgment channel unavailable (${ackPath}): ${error.message}`);
570
+ }
571
+ const marker = `printf '%s%s\\n' ${shq(DISPATCH_START_PREFIX)} ${shq(suffix)}`;
572
+ const line = `${marker} > ${shq(ackPath)} || exit 1; ${marker}; ${cmd}`;
573
+ const deadline = this.time.now() + DISPATCH_ACK_TIMEOUT_MS;
574
+ try {
575
+ const sent = await this.herdr(`pane run ${shq(pane)} ${shq(line)}`, slot.cwd);
576
+ if (sent.code !== 0) {
577
+ throw new DeliveryCorruptedError(pane, `pane run failed: ${sent.stderr || sent.stdout}`, "");
578
+ }
579
+ // Fast path: herdr's own event-driven watch on the pane's output. A match is causal — the pane
580
+ // cannot emit the joined marker without having RUN the line. A MISS is not evidence of
581
+ // anything: this watch subscribes after the request, and a full-screen repaint can swallow the
582
+ // very line it is watching for, which is how a successful launch got dispatched twice. So a
583
+ // miss falls through to the channel the line wrote for itself, which nothing can repaint.
584
+ const seen = await this.herdr(`pane wait-output ${shq(pane)} --match ${shq(nonce)} --timeout ${DISPATCH_ACK_TIMEOUT_MS}`, slot.cwd, DISPATCH_ACK_TIMEOUT_MS + 15_000);
585
+ if (this.waitOk(seen.code, seen.stdout))
586
+ return;
587
+ if (await this.awaitDispatchAck(ackPath, nonce, deadline))
588
+ return;
589
+ // Only now, and only as evidence for the operator: the transcript explains the failure, it
590
+ // never decides one. A pane that looks like it ran the line but never wrote the ack did not.
591
+ const transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
592
+ throw new DeliveryCorruptedError(pane, `START nonce ${nonce} never appeared — the line never ran`, transcript);
593
+ }
594
+ finally {
595
+ try {
596
+ unlinkSync(ackPath);
597
+ }
598
+ catch { /* never written, or already reaped — nothing to clean */ }
599
+ }
600
+ }
601
+ // The durable half of the acknowledgment: the file the delivered line wrote before it launched
602
+ // anything. Checked at least once even when the shared dispatch deadline has already passed, so a
603
+ // watch that spent the whole window missing the line still gets the truth from the line itself.
604
+ async awaitDispatchAck(ackPath, nonce, deadline) {
605
+ for (;;) {
606
+ try {
607
+ if (readFileSync(ackPath, "utf8").includes(nonce))
608
+ return true;
609
+ }
610
+ catch {
611
+ /* the line has not reached the marker yet — or never will, which the deadline decides */
612
+ }
613
+ if (this.time.now() >= deadline)
614
+ return false;
615
+ await this.time.sleep(DISPATCH_ACK_POLL_MS);
616
+ }
617
+ }
618
+ // OBS-253: a fresh pane is a SIBLING inside this slot's own tab. Split first so the tab can never
619
+ // empty, then close the wedged pane and take its durable label — every other driver call addresses
620
+ // this slot through that label or the delivered-pane pin, so no caller observes the swap.
621
+ async freshPane(slot, wedged) {
622
+ if (!this.ws)
623
+ return null;
624
+ const sp = await this.herdr(`pane split ${shq(wedged)} --direction down --no-focus --cwd ${shq(slot.cwd)}`);
625
+ if (sp.code !== 0)
626
+ return null;
627
+ let pane;
628
+ try {
629
+ pane = JSON.parse(sp.stdout).result?.pane?.pane_id ?? undefined;
630
+ }
631
+ catch {
632
+ return null;
633
+ }
634
+ if (typeof pane !== "string" || !pane)
635
+ return null;
636
+ // The wedged pane is never re-pressed, only reaped — and the reap is a POSTCONDITION, not a
637
+ // best-effort. It still carries this slot's durable label, so a close that fails (or succeeds
638
+ // and frees nothing) leaves two panes answering to one name and `pane list` resolving it by
639
+ // whichever comes first. Verify the name is free BEFORE the replacement is renamed, seeded or
640
+ // dispatched: a fresh pane that is only PROBABLY the slot is not a fresh pane (v1.85 T5 review).
641
+ const closed = await this.herdr(`pane close ${shq(wedged)}`);
642
+ if (closed.code !== 0 || (await this.staleLabelPanes(slot.name, pane)).length > 0) {
643
+ await this.herdr(`pane close ${shq(pane)}`);
644
+ return null;
645
+ }
646
+ const rn = await this.herdr(`pane rename ${shq(pane)} ${shq(slot.name)}`);
647
+ if (rn.code !== 0 || (await this.namedPaneId(slot.name)) !== pane) {
648
+ await this.herdr(`pane close ${shq(pane)}`);
649
+ return null;
650
+ }
651
+ // VIS-10 hole 3: a split pane is a bare shell with FRESH env, so it is untargeted until seeded.
652
+ const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(slot.name, "")))}; ${herdrSealShellPrefix()}`)}`, slot.cwd);
653
+ if (seed.code !== 0) {
654
+ await this.herdr(`pane close ${shq(pane)}`);
655
+ return null;
656
+ }
657
+ return pane;
658
+ }
659
+ // OBS-85 typed delivery, now reserved for real TUI turns: type WITHOUT enter, read the pane back
660
+ // — `wait output --match` checks the same unwrapped transcript pane read exposes, event-driven so
661
+ // wrap and render timing can't race the check — and press Enter only when that read-back contains
662
+ // the typed command. A corrupted paste is captured (pane read), cleared (C-u), and retyped,
663
+ // bounded; persistent corruption fails closed WITH the captured transcript.
664
+ async deliverTyped(slot, cmd, pane) {
665
+ const inputBox = this.inputBoxes.get(slot);
666
+ const missing = missingInputStateDeclarations(inputBox);
667
+ if (missing.length > 0) {
668
+ throw new Error(`herdr refuses typed delivery to ${slot.name}: the adapter has not declared its input states `
669
+ + `(missing: ${missing.join(", ")}) — only a declared box can acknowledge a submission, and `
670
+ + `nothing else may stand in for it (OBS-140)`);
671
+ }
672
+ const readiness = await this.awaitDeliveryReadiness(slot, cmd, pane, inputBox);
483
673
  let transcript = "";
484
674
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
485
675
  if (attempt > 0) {
@@ -487,7 +677,7 @@ export class HerdrDriver {
487
677
  // line only after two consecutive pane reads agree; an already-stable frame returns on the
488
678
  // first fresh read without a timer. A changing pane is bounded and preserves OBS-85's
489
679
  // fail-closed error instead of guessing from an adapter fingerprint.
490
- const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, this.inputBoxes.get(slot));
680
+ const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox);
491
681
  transcript = settled.transcript;
492
682
  if (!settled.ok) {
493
683
  throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
@@ -506,24 +696,14 @@ export class HerdrDriver {
506
696
  throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
507
697
  const back = await this.herdr(`pane wait-output ${shq(pane)} --match ${shq(cmd)} --timeout ${DELIVERY_VERIFY_TIMEOUT_MS}`, slot.cwd, DELIVERY_VERIFY_TIMEOUT_MS + 15_000);
508
698
  if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
509
- if (verifySubmission) {
510
- await this.submitVerifiedDelivery(slot, cmd, pane, readiness);
511
- }
512
- else {
513
- const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
514
- if (enter.code !== 0)
515
- throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
516
- }
699
+ await this.submitVerifiedDelivery(slot, pane, inputBox, readiness);
517
700
  return;
518
701
  }
519
702
  // capture the corrupted delivery BEFORE clearing it — the OBS-85 byte-level evidence
520
703
  transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
521
704
  }
522
- throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
705
+ throw new DeliveryCorruptedError(pane, `${DELIVERY_ATTEMPTS} typed attempts — enter never pressed (OBS-85)`, transcript);
523
706
  }
524
- // The narrator launches a perpetual shell watch, not an adapter-backed input interface. Keep
525
- // OBS-85's readiness + paste read-back and the historical single Enter, while worker/gate runs
526
- // continue through positive post-Enter evidence in submitVerifiedDelivery.
527
707
  // OBS-201: liveness nudge — deliver one message into the live worker TUI through the exact
528
708
  // pincer every dispatch uses (readiness stable-frame, type-without-Enter, read-back, C-u clear
529
709
  // on corruption, verified submit), serialized on the deliveryQueue so it can never interleave
@@ -536,23 +716,24 @@ export class HerdrDriver {
536
716
  if (!pinned)
537
717
  return false;
538
718
  try {
539
- await this.deliveryQueue(() => this.deliver(slot, message, pinned));
719
+ await this.deliveryQueue(() => this.deliverTyped(slot, message, pinned));
540
720
  return true;
541
721
  }
542
722
  catch {
543
723
  return false; // the daemon journals worker-nudge-failed and falls back to the stall window
544
724
  }
545
725
  }
726
+ // The narrator launches a perpetual shell watch, not an adapter-backed input interface — a shell
727
+ // dispatch like any other, delivered atomically and acknowledged by its own START nonce.
546
728
  async deliverPersistentShellCommand(slot, cmd) {
547
729
  return this.deliveryQueue(async () => {
548
730
  const pane = await this.paneId(slot);
549
- await this.deliver(slot, cmd, pane, false);
731
+ await this.deliverAtomic(slot, cmd, pane);
550
732
  this.deliveredPanes.set(slot, pane);
551
733
  });
552
734
  }
553
- async submitVerifiedDelivery(slot, cmd, pane, readiness) {
735
+ async submitVerifiedDelivery(slot, pane, inputBox, readiness) {
554
736
  let transcript = "";
555
- const inputBox = this.inputBoxes.get(slot);
556
737
  // The base window preserves OBS-140's bounded behavior. A slow readiness observation grants
557
738
  // the same measured time once more for submit paint, capped by the readiness bound itself.
558
739
  const baseVerifyMs = (DELIVERY_SETTLE_READ_ATTEMPTS - 1) * DELIVERY_SETTLE_POLL_MS;
@@ -564,15 +745,15 @@ export class HerdrDriver {
564
745
  // Reuse the existing settle-read window. A first-read success returns before any timer; only
565
746
  // a prompt that still occupies the delivery target spends the bounded settle window. This
566
747
  // verification always completes before a possible re-press, so a slow submit cannot duplicate.
567
- const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, cmd, inputBox), verifyWindowMs);
748
+ const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, inputBox), verifyWindowMs);
568
749
  transcript = settled.transcript;
569
750
  if (settled.ok)
570
751
  return;
571
752
  if (settled.readFailed) {
572
- throw new Error(`herdr delivery corrupted — submission verification failed, refusing to re-press Enter (OBS-140); pane transcript:\n${transcript}`);
753
+ throw new DeliveryCorruptedError(pane, "submission verification read failed, refusing to re-press Enter (OBS-140)", transcript);
573
754
  }
574
755
  }
575
- throw new Error(`herdr delivery corrupted after ${DELIVERY_SUBMIT_ATTEMPTS} submit attempts — submission never registered (OBS-140); pane transcript:\n${transcript}`);
756
+ throw new DeliveryCorruptedError(pane, `${DELIVERY_SUBMIT_ATTEMPTS} submit attempts — submission never registered (OBS-140)`, transcript);
576
757
  }
577
758
  async settleDeliveryLine(pane, cwd, initialTranscript, inputBox, accept, settleWindowMs) {
578
759
  let transcript = initialTranscript;
@@ -604,10 +785,11 @@ export class HerdrDriver {
604
785
  }
605
786
  return { ok: false, transcript, recognizedInputBox: false };
606
787
  }
607
- async awaitDeliveryReadiness(slot, cmd, pane) {
608
- const inputBox = this.inputBoxes.get(slot);
609
- const requireInputBox = inputBox !== undefined && inputBox.launchCommand?.(cmd) !== true;
610
- const timeoutMs = inputBox?.readinessTimeoutMs ?? DELIVERY_READINESS_TIMEOUT_MS;
788
+ // Readiness now serves typed delivery only, so the target is always the adapter's declared box:
789
+ // the "no declared box, so assume ready when the pane doesn't already show our command" branch is
790
+ // gone with the rest of the typed-text inference (v1.85 T5).
791
+ async awaitDeliveryReadiness(slot, cmd, pane, inputBox) {
792
+ const timeoutMs = inputBox.readinessTimeoutMs ?? DELIVERY_READINESS_TIMEOUT_MS;
611
793
  const started = this.time.now();
612
794
  let previous;
613
795
  let transcript = "";
@@ -635,11 +817,9 @@ export class HerdrDriver {
635
817
  }
636
818
  if (previous !== undefined) {
637
819
  const stableFrame = read.stdout === previous;
638
- const targetReady = stableFrame && (requireInputBox
639
- ? matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)
640
- : !this.deliveryMatches(read.stdout, cmd));
641
- if (targetReady)
820
+ if (stableFrame && matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)) {
642
821
  return { waitedMs, timeoutMs, transcript: read.stdout };
822
+ }
643
823
  }
644
824
  previous = read.stdout;
645
825
  const remaining = timeoutMs - waitedMs;
@@ -888,7 +1068,10 @@ export class HerdrDriver {
888
1068
  /* cosmetic — visibility hygiene never fails the run */
889
1069
  }
890
1070
  }
891
- worktree(repo, branch, baseRef) {
892
- return createWorktree(repo, branch, baseRef);
1071
+ async worktree(repo, branch, baseRef) {
1072
+ const worktree = await createWorktree(repo, branch, baseRef);
1073
+ this.journalRoots.set(repo, repo);
1074
+ this.journalRoots.set(worktree, repo);
1075
+ return worktree;
893
1076
  }
894
1077
  }
@@ -14,6 +14,7 @@ export interface BaselineWarning {
14
14
  commands: string[];
15
15
  reason: string;
16
16
  }
17
+ export declare const UNRECOGNIZED_FAILURE = "<unrecognized failure output>";
17
18
  export declare function fingerprint(output: string): string[];
18
19
  export declare function detectGateCommands(repoRoot: string, cfg: TickmarkrConfig): Record<string, string>;
19
20
  export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;