tickmarkr 1.76.0 → 1.78.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.
@@ -137,16 +137,37 @@ export function confirmKimiSeedBanner(banner, assignedModel) {
137
137
  }
138
138
  return { ok: true, sessionId: kimiBannerSessionId(banner) };
139
139
  }
140
- // v1.75 T1 / OBS-136: this readiness line is rendered inside Kimi Code's bordered steady-state
141
- // input box. The adapter owns the fingerprint; the driver only consults declarations generically.
140
+ const kimiTuiLaunch = (model) => `kimi -y -m ${shq(model)}`;
141
+ const KIMI_ANSI_SGR_RE = /\u001B\[[0-9;]*m/g;
142
+ const KIMI_EDITOR_TOP_RE = /^╭─+╮$/;
143
+ const KIMI_EDITOR_INPUT_RE = /^│ > .*│$/;
144
+ const KIMI_EDITOR_EMPTY_RE = /^│ > +│$/;
145
+ const KIMI_EDITOR_BOTTOM_RE = /^╰─+╯$/;
146
+ function matchesKimiEditorBox(paneText, empty) {
147
+ const lines = paneText.replace(KIMI_ANSI_SGR_RE, "").split("\n");
148
+ const input = empty ? KIMI_EDITOR_EMPTY_RE : KIMI_EDITOR_INPUT_RE;
149
+ return lines.some((line, index) => input.test(line)
150
+ && index > 0
151
+ && index + 1 < lines.length
152
+ && KIMI_EDITOR_TOP_RE.test(lines[index - 1])
153
+ && KIMI_EDITOR_BOTTOM_RE.test(lines[index + 1]));
154
+ }
155
+ // v1.75 T1 / OBS-136: Kimi's actual editor is a three-line bordered box whose content row starts
156
+ // `│ > `. The welcome panel separately contains "Send /help..." and is only launch-liveness
157
+ // evidence (OBS-142), so it must never satisfy this declaration. The adapter owns the full matcher,
158
+ // empty-state matcher, launch distinction, and bounded cold-start window; the driver stays generic.
142
159
  export const KIMI_INPUT_BOX = declareInputBox("kimi", {
143
- fingerprint: "Send /help for help information.",
160
+ fingerprint: "│ > ",
161
+ match: (paneText) => matchesKimiEditorBox(paneText, false),
162
+ emptyMatch: (paneText) => matchesKimiEditorBox(paneText, true),
163
+ launchCommand: (command) => command.startsWith("kimi -y -m "),
164
+ readinessTimeoutMs: 15_000,
144
165
  });
145
166
  // Shared launch-then-seed surface (T6) + banner confirm (T7/T2). One definition so the adapter
146
167
  // property and the daemon's generic runInteractiveSeed path cannot drift.
147
168
  const KIMI_SEED = {
148
169
  // v1.76 T2 / OBS-141: 0.29.0 resolves only the full config.toml model key on the first turn.
149
- launch: (model) => `kimi -y -m ${shq(model)}`,
170
+ launch: kimiTuiLaunch,
150
171
  readinessMatch: "Send /help for help information.",
151
172
  seedLine: (promptFile) => `Read ${promptFile} and do exactly what it says.`,
152
173
  confirmBanner: confirmKimiSeedBanner,
@@ -84,10 +84,15 @@ export interface TrustDialog {
84
84
  export declare function matchesTrustDialog(paneText: string, dialog: TrustDialog): boolean;
85
85
  export interface InputBox {
86
86
  fingerprint: string;
87
+ match?(paneText: string): boolean;
88
+ emptyMatch?(paneText: string): boolean;
89
+ launchCommand?(command: string): boolean;
90
+ readinessTimeoutMs?: number;
87
91
  }
88
92
  export declare function declareInputBox(adapterId: string, inputBox: InputBox): InputBox;
89
93
  export declare function declaredInputBoxForWorkerName(workerName: string): InputBox | undefined;
90
94
  export declare function matchesInputBox(paneText: string, inputBox: InputBox): boolean;
95
+ export declare function matchesEmptyInputBox(paneText: string, inputBox: InputBox): boolean;
91
96
  export interface WorkerAdapter {
92
97
  id: string;
93
98
  vendor: string;
@@ -40,7 +40,10 @@ export function declaredInputBoxForWorkerName(workerName) {
40
40
  return adapterId === undefined ? undefined : inputBoxes.get(adapterId);
41
41
  }
42
42
  export function matchesInputBox(paneText, inputBox) {
43
- return paneText.includes(inputBox.fingerprint);
43
+ return inputBox.match?.(paneText) ?? paneText.includes(inputBox.fingerprint);
44
+ }
45
+ export function matchesEmptyInputBox(paneText, inputBox) {
46
+ return inputBox.emptyMatch?.(paneText) === true;
44
47
  }
45
48
  export function channelsFromConfig(adapterId, cfg) {
46
49
  const e = cfg.tiers[adapterId];
@@ -2,11 +2,22 @@ import { type ExecutorDriver, type NotifyOpts, type Slot, type SlotOpts } from "
2
2
  export declare const TRAILER_SAFE_FLOOR_COLS = 108;
3
3
  export declare const TRAILER_WIDTH_MARGIN = 2;
4
4
  export declare const DELIVERY_ATTEMPTS = 3;
5
+ export interface HerdrTimeSource {
6
+ now: () => number;
7
+ sleep: (ms: number) => Promise<void>;
8
+ }
9
+ export declare class DeliveryReadinessError extends Error {
10
+ readonly waitedMs: number;
11
+ readonly transcript: string;
12
+ readonly phase = "READINESS";
13
+ constructor(waitedMs: number, transcript: string);
14
+ }
5
15
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
6
16
  export declare function workerSplitDirection(paneCols: number | null, safeFloor?: number, margin?: number): "right" | "down";
7
17
  export declare class HerdrDriver implements ExecutorDriver {
8
18
  private bin;
9
19
  private workersPerTab;
20
+ private time;
10
21
  id: string;
11
22
  interactive: boolean;
12
23
  private groups;
@@ -18,12 +29,13 @@ export declare class HerdrDriver implements ExecutorDriver {
18
29
  private ws;
19
30
  private callerPane;
20
31
  private watches;
21
- constructor(bin?: string, workersPerTab?: number);
32
+ constructor(bin?: string, workersPerTab?: number, time?: HerdrTimeSource);
22
33
  private serial;
23
34
  private deliveryQueue;
24
35
  private reserveDispatch;
25
36
  private verifyPaneIdentityBinding;
26
37
  private deliveryMatches;
38
+ private shellExecutionEchoed;
27
39
  private submissionRegistered;
28
40
  static available(): boolean;
29
41
  private herdr;
@@ -41,8 +53,10 @@ export declare class HerdrDriver implements ExecutorDriver {
41
53
  private joinGroup;
42
54
  run(slot: Slot, cmd: string): Promise<void>;
43
55
  private deliver;
56
+ private deliverPersistentShellCommand;
44
57
  private submitVerifiedDelivery;
45
58
  private settleDeliveryLine;
59
+ private awaitDeliveryReadiness;
46
60
  private deliveryReadMatches;
47
61
  private waitOk;
48
62
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
@@ -1,4 +1,4 @@
1
- import { declaredInputBoxForWorkerName, matchesInputBox, shq } from "../adapters/types.js";
1
+ import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, shq } from "../adapters/types.js";
2
2
  import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
3
3
  import { createWorktree, sh } from "../run/git.js";
4
4
  import { herdrSealShellPrefix } from "./subprocess.js";
@@ -13,6 +13,25 @@ const DELIVERY_VERIFY_TIMEOUT_MS = 2000; // per attempt — a paste that hasn't
13
13
  const DELIVERY_READ_LINES = 80;
14
14
  const DELIVERY_SETTLE_READ_ATTEMPTS = 6;
15
15
  const DELIVERY_SETTLE_POLL_MS = 100;
16
+ const DELIVERY_READINESS_TIMEOUT_MS = 1_000;
17
+ const SYSTEM_TIME = {
18
+ now: () => Date.now(),
19
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
20
+ };
21
+ // OBS-142: a typed identity lets the daemon's next task distinguish cold-start variance from
22
+ // structural driver faults without parsing prose. The message also carries the bounded wait and
23
+ // final pane evidence so a terminal failure is diagnosable on its own.
24
+ export class DeliveryReadinessError extends Error {
25
+ waitedMs;
26
+ transcript;
27
+ phase = "READINESS";
28
+ constructor(waitedMs, transcript) {
29
+ super(`herdr delivery READINESS failed after ${waitedMs}ms — interface never became interactive (OBS-142); pane transcript:\n${transcript}`);
30
+ this.waitedMs = waitedMs;
31
+ this.transcript = transcript;
32
+ this.name = "DeliveryReadinessError";
33
+ }
34
+ }
16
35
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
17
36
  export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
18
37
  if (paneCols == null || paneCols <= 0)
@@ -22,6 +41,7 @@ export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_CO
22
41
  export class HerdrDriver {
23
42
  bin;
24
43
  workersPerTab;
44
+ time;
25
45
  id = "herdr";
26
46
  interactive = true;
27
47
  groups = new Map();
@@ -40,9 +60,10 @@ export class HerdrDriver {
40
60
  ws = process.env.HERDR_WORKSPACE_ID;
41
61
  callerPane = process.env.HERDR_PANE_ID;
42
62
  watches = new Map();
43
- constructor(bin = "herdr", workersPerTab = 3) {
63
+ constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME) {
44
64
  this.bin = bin;
45
65
  this.workersPerTab = workersPerTab;
66
+ this.time = time;
46
67
  }
47
68
  serial(fn) {
48
69
  const p = this.groupSerial.then(fn, fn);
@@ -96,17 +117,38 @@ export class HerdrDriver {
96
117
  const needle = norm(cmd);
97
118
  return needle.length > 0 && hay.includes(needle);
98
119
  }
99
- // Submission succeeds when the typed prompt disappears, or when it has moved above a fresh
100
- // adapter-declared input box (the prompt is now transcript, not input). Shell-line delivery uses
101
- // the same normalized seam: a prompt still at the bottom ends the pane text; output/a fresh prompt
102
- // after it proves Enter registered. No adapter-specific fingerprint lives in the driver.
120
+ shellExecutionEchoed(transcript, cmd) {
121
+ const norm = (s) => s.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\s+/g, " ").trim();
122
+ const needle = norm(cmd);
123
+ if (!needle)
124
+ return false;
125
+ return transcript.split("\n").some((rawLine) => {
126
+ const line = norm(rawLine);
127
+ if (line === needle || !line.endsWith(needle))
128
+ return false;
129
+ const prefix = line.slice(0, -needle.length).trimEnd();
130
+ if (/[│┃╭╰┌└]/.test(prefix))
131
+ return false;
132
+ return /^(?:[$%#>]|[➜❯❱›»λ])(?:\s|$)/.test(prefix)
133
+ || /[$%#>✗❯❱›»λ]$/.test(prefix);
134
+ });
135
+ }
136
+ // Submission requires positive post-Enter evidence. A prompt echoed above a fresh input target
137
+ // proves registration; a declared box that is visibly empty after the verified paste also proves
138
+ // it consumed the prompt. At the launch stage, a structurally prompt-prefixed shell execution echo
139
+ // is stronger evidence than waiting for first paint from the launched interface (OBS-144).
140
+ // Prompt absence alone is never success (OBS-142).
103
141
  submissionRegistered(transcript, cmd, inputBox) {
104
142
  const norm = (s) => s.replace(/\s+/g, "");
105
143
  const hay = norm(transcript);
106
144
  const needle = norm(cmd);
107
145
  const promptAt = hay.lastIndexOf(needle);
108
- if (needle.length === 0 || promptAt < 0)
146
+ if (needle.length === 0)
147
+ return false;
148
+ if (inputBox?.launchCommand?.(cmd) === true && this.shellExecutionEchoed(transcript, cmd))
109
149
  return true;
150
+ if (promptAt < 0)
151
+ return inputBox !== undefined && matchesEmptyInputBox(transcript, inputBox);
110
152
  if (inputBox && matchesInputBox(transcript, inputBox)) {
111
153
  return hay.lastIndexOf(norm(inputBox.fingerprint)) > promptAt;
112
154
  }
@@ -409,7 +451,8 @@ export class HerdrDriver {
409
451
  this.deliveredPanes.set(slot, paneId);
410
452
  });
411
453
  }
412
- async deliver(slot, cmd, pane) {
454
+ async deliver(slot, cmd, pane, verifySubmission = true) {
455
+ const readiness = await this.awaitDeliveryReadiness(slot, cmd, pane);
413
456
  let transcript = "";
414
457
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
415
458
  if (attempt > 0) {
@@ -436,7 +479,14 @@ export class HerdrDriver {
436
479
  throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
437
480
  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);
438
481
  if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
439
- await this.submitVerifiedDelivery(slot, cmd, pane);
482
+ if (verifySubmission) {
483
+ await this.submitVerifiedDelivery(slot, cmd, pane, readiness);
484
+ }
485
+ else {
486
+ const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
487
+ if (enter.code !== 0)
488
+ throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
489
+ }
440
490
  return;
441
491
  }
442
492
  // capture the corrupted delivery BEFORE clearing it — the OBS-85 byte-level evidence
@@ -444,9 +494,23 @@ export class HerdrDriver {
444
494
  }
445
495
  throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
446
496
  }
447
- async submitVerifiedDelivery(slot, cmd, pane) {
497
+ // The narrator launches a perpetual shell watch, not an adapter-backed input interface. Keep
498
+ // OBS-85's readiness + paste read-back and the historical single Enter, while worker/gate runs
499
+ // continue through positive post-Enter evidence in submitVerifiedDelivery.
500
+ async deliverPersistentShellCommand(slot, cmd) {
501
+ return this.deliveryQueue(async () => {
502
+ const pane = await this.paneId(slot);
503
+ await this.deliver(slot, cmd, pane, false);
504
+ this.deliveredPanes.set(slot, pane);
505
+ });
506
+ }
507
+ async submitVerifiedDelivery(slot, cmd, pane, readiness) {
448
508
  let transcript = "";
449
509
  const inputBox = this.inputBoxes.get(slot);
510
+ // The base window preserves OBS-140's bounded behavior. A slow readiness observation grants
511
+ // the same measured time once more for submit paint, capped by the readiness bound itself.
512
+ const baseVerifyMs = (DELIVERY_SETTLE_READ_ATTEMPTS - 1) * DELIVERY_SETTLE_POLL_MS;
513
+ const verifyWindowMs = Math.min(baseVerifyMs + readiness.timeoutMs, baseVerifyMs + readiness.waitedMs);
450
514
  for (let attempt = 0; attempt < DELIVERY_SUBMIT_ATTEMPTS; attempt++) {
451
515
  const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
452
516
  if (enter.code !== 0)
@@ -454,7 +518,7 @@ export class HerdrDriver {
454
518
  // Reuse the existing settle-read window. A first-read success returns before any timer; only
455
519
  // a prompt that still occupies the delivery target spends the bounded settle window. This
456
520
  // verification always completes before a possible re-press, so a slow submit cannot duplicate.
457
- const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, cmd, inputBox));
521
+ const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, cmd, inputBox), verifyWindowMs);
458
522
  transcript = settled.transcript;
459
523
  if (settled.ok)
460
524
  return;
@@ -464,9 +528,12 @@ export class HerdrDriver {
464
528
  }
465
529
  throw new Error(`herdr delivery corrupted after ${DELIVERY_SUBMIT_ATTEMPTS} submit attempts — submission never registered (OBS-140); pane transcript:\n${transcript}`);
466
530
  }
467
- async settleDeliveryLine(pane, cwd, initialTranscript, inputBox, accept) {
531
+ async settleDeliveryLine(pane, cwd, initialTranscript, inputBox, accept, settleWindowMs) {
468
532
  let transcript = initialTranscript;
469
- for (let readAttempt = 0; readAttempt < DELIVERY_SETTLE_READ_ATTEMPTS; readAttempt++) {
533
+ const readAttempts = settleWindowMs === undefined
534
+ ? DELIVERY_SETTLE_READ_ATTEMPTS
535
+ : Math.max(DELIVERY_SETTLE_READ_ATTEMPTS, Math.floor(settleWindowMs / DELIVERY_SETTLE_POLL_MS) + 1);
536
+ for (let readAttempt = 0; readAttempt < readAttempts; readAttempt++) {
470
537
  const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, cwd);
471
538
  if (read.code !== 0)
472
539
  return { ok: false, transcript: read.stdout || transcript, recognizedInputBox: false, readFailed: true };
@@ -485,12 +552,58 @@ export class HerdrDriver {
485
552
  };
486
553
  }
487
554
  transcript = read.stdout;
488
- if (readAttempt < DELIVERY_SETTLE_READ_ATTEMPTS - 1) {
489
- await new Promise((resolve) => setTimeout(resolve, DELIVERY_SETTLE_POLL_MS));
555
+ if (readAttempt < readAttempts - 1) {
556
+ await this.time.sleep(DELIVERY_SETTLE_POLL_MS);
490
557
  }
491
558
  }
492
559
  return { ok: false, transcript, recognizedInputBox: false };
493
560
  }
561
+ async awaitDeliveryReadiness(slot, cmd, pane) {
562
+ const inputBox = this.inputBoxes.get(slot);
563
+ const requireInputBox = inputBox !== undefined && inputBox.launchCommand?.(cmd) !== true;
564
+ const timeoutMs = inputBox?.readinessTimeoutMs ?? DELIVERY_READINESS_TIMEOUT_MS;
565
+ const started = this.time.now();
566
+ let previous;
567
+ let transcript = "";
568
+ let reads = 0;
569
+ while (true) {
570
+ const elapsedBeforeRead = this.time.now() - started;
571
+ const remainingBeforeRead = timeoutMs - elapsedBeforeRead;
572
+ if (remainingBeforeRead <= 0)
573
+ throw new DeliveryReadinessError(elapsedBeforeRead, transcript);
574
+ const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd, Math.max(1, Math.ceil(remainingBeforeRead)));
575
+ reads++;
576
+ transcript = read.stdout || transcript;
577
+ const waitedMs = this.time.now() - started;
578
+ if (read.code !== 0) {
579
+ // Once at least one valid frame has been observed, a read that consumes the remainder of
580
+ // the readiness budget is the bounded window expiring, not a new submission/protocol
581
+ // identity. A first-read timeout and every non-timeout read failure remain structural.
582
+ if (read.timedOut && previous !== undefined && waitedMs >= timeoutMs) {
583
+ throw new DeliveryReadinessError(waitedMs, transcript);
584
+ }
585
+ const detail = read.stderr || read.stdout || `exit ${read.code}`;
586
+ throw new Error(`herdr pane read failed during readiness${read.timedOut ? " (timed out)" : ""}: ${detail}`);
587
+ }
588
+ if (previous !== undefined) {
589
+ const stableFrame = read.stdout === previous;
590
+ const targetReady = stableFrame && (requireInputBox
591
+ ? matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)
592
+ : !this.deliveryMatches(read.stdout, cmd));
593
+ if (targetReady)
594
+ return { waitedMs, timeoutMs, transcript: read.stdout };
595
+ }
596
+ previous = read.stdout;
597
+ const remaining = timeoutMs - waitedMs;
598
+ if (remaining <= 0)
599
+ throw new DeliveryReadinessError(waitedMs, transcript);
600
+ // The second read is immediate: an already-painted stable target proves readiness with no
601
+ // added delay. Only observed change spends a poll interval, and every path remains bounded.
602
+ if (reads > 1) {
603
+ await this.time.sleep(Math.min(DELIVERY_SETTLE_POLL_MS, remaining));
604
+ }
605
+ }
606
+ }
494
607
  async deliveryReadMatches(pane, cmd, cwd) {
495
608
  const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, cwd);
496
609
  return read.code === 0 && this.deliveryMatches(read.stdout, cmd);
@@ -680,7 +793,7 @@ export class HerdrDriver {
680
793
  const s = await this.watchSlot(cwd, name);
681
794
  this.watches.set(name, s);
682
795
  try {
683
- await this.run(s, command);
796
+ await this.deliverPersistentShellCommand(s, command);
684
797
  }
685
798
  catch (err) {
686
799
  this.watches.delete(name);
@@ -9,6 +9,7 @@ import { allAdapters, discoverChannels, getAdapter, probeAll, readDoctor } from
9
9
  import { addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
10
10
  import { bannerShell, paneDispatchCommand } from "../brand.js";
11
11
  import { globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
12
+ import { DeliveryReadinessError } from "../drivers/herdr.js";
12
13
  import { herdrSealShellPrefix, SubprocessDriver } from "../drivers/subprocess.js";
13
14
  import { formatOwnedName } from "../drivers/types.js";
14
15
  import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
@@ -833,6 +834,38 @@ export async function runDaemon(repoRoot, opts = {}) {
833
834
  let earlyLaunchDead = false;
834
835
  let settleParsed;
835
836
  let seedResult;
837
+ const handleDeliveryReadiness = async (error) => {
838
+ journal.append("delivery-readiness-failed", t.id, {
839
+ attempt,
840
+ waitedMs: error.waitedMs,
841
+ transcript: error.transcript,
842
+ });
843
+ if (keepOpen)
844
+ keptSlots.push(slot);
845
+ else
846
+ await closeSlot(slot);
847
+ feedback = `delivery readiness failed after ${error.waitedMs}ms; pane transcript:\n${error.transcript}`;
848
+ const step = r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)];
849
+ journal.append("escalation", t.id, { step, attempt: attempt + 1 });
850
+ await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
851
+ if (step === "retry")
852
+ return true;
853
+ if (step === "escalate") {
854
+ const next = failover("escalate");
855
+ if (next) {
856
+ assignment = next;
857
+ tried.push(channelKey(next));
858
+ return true;
859
+ }
860
+ // no channel left — fall through to a consult
861
+ }
862
+ if (step === "escalate" || step === "consult") {
863
+ const v = await runConsult("delivery-readiness", error.transcript, feedback, []);
864
+ return applyVerdict(v, attempt + 1, "dispatch");
865
+ }
866
+ await park(t, "escalation ladder exhausted", "ladder-exhausted", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
867
+ return false;
868
+ };
836
869
  if (interactive) {
837
870
  // v1.2 interactive: the TUI doesn't exit on completion — the trailer is the finish line.
838
871
  // The exit wrapper still fires if the TUI dies (crash/quit): fast-fail instead of burning the timeout.
@@ -842,11 +875,29 @@ export async function runDaemon(repoRoot, opts = {}) {
842
875
  // v1.69 T6: launch the real TUI without a prompt, wait for readiness, inject one seed turn,
843
876
  // then fall through to the normal trailer harvest. A failed seed is recorded as a finished
844
877
  // failure rather than allowed to race the trailer wait.
845
- seedResult = await runInteractiveSeed({ driver, slot, adapter, assignment, promptFile, taskTimeoutMinutes });
878
+ try {
879
+ seedResult = await runInteractiveSeed({ driver, slot, adapter, assignment, promptFile, taskTimeoutMinutes });
880
+ }
881
+ catch (error) {
882
+ if (!(error instanceof DeliveryReadinessError))
883
+ throw error;
884
+ if (await handleDeliveryReadiness(error))
885
+ continue attempts;
886
+ return;
887
+ }
846
888
  output = seedResult.output;
847
889
  }
848
890
  else {
849
- await driver.run(slot, paneDispatchCommand(dispatchScript));
891
+ try {
892
+ await driver.run(slot, paneDispatchCommand(dispatchScript));
893
+ }
894
+ catch (error) {
895
+ if (!(error instanceof DeliveryReadinessError))
896
+ throw error;
897
+ if (await handleDeliveryReadiness(error))
898
+ continue attempts;
899
+ return;
900
+ }
850
901
  output = await driver.read(slot, 1000);
851
902
  }
852
903
  if (seedResult?.seedFailed) {
@@ -976,7 +1027,16 @@ export async function runDaemon(repoRoot, opts = {}) {
976
1027
  }
977
1028
  }
978
1029
  else {
979
- await driver.run(slot, paneDispatchCommand(dispatchScript));
1030
+ try {
1031
+ await driver.run(slot, paneDispatchCommand(dispatchScript));
1032
+ }
1033
+ catch (error) {
1034
+ if (!(error instanceof DeliveryReadinessError))
1035
+ throw error;
1036
+ if (await handleDeliveryReadiness(error))
1037
+ continue attempts;
1038
+ return;
1039
+ }
980
1040
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
981
1041
  // v1.76: same monotonic-progress measure as the interactive site; harvest stays raw.
982
1042
  const stallWindowMs = taskTimeoutMinutes * 60_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.76.0",
3
+ "version": "1.78.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",