tickmarkr 2.0.0 → 2.1.1

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.
@@ -0,0 +1,879 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { shq } from "../adapters/types.js";
4
+ import { createWorktree, sh } from "../run/git.js";
5
+ import { MAX_BUF } from "./subprocess.js";
6
+ import { formatOwnedName, panesToClose } from "./types.js";
7
+ // Orca (onorca.dev) as a third execution surface beside herdr and subprocess. tickmarkr keeps
8
+ // worktrees, routing, gates, journal and merges; orca supplies visible terminals only. Everything
9
+ // here is bound by the 1.4.186 conformance spike
10
+ // (.planning/assessments/2026-08-21-orca-driver-conformance.md, CONFORMANCE-END) and the
11
+ // recorded refusal transport (process rc 1 + ok:false on stdout):
12
+ //
13
+ // - reads arrive as `result.terminal.tail` line arrays with line-indexed cursors and a `status`
14
+ // field on the same object (C2); a CLOSED terminal answers ok:true with its own dead record
15
+ // and retained scrollback (C4), so liveness is never inferred from a read that returned bytes;
16
+ // - orca has no output-pattern wait, so waitOutput is a bounded cursor-paged polling matcher (C2);
17
+ // `--command` runs inside an interactive wrapper shell that OUTLIVES it, so `wait --for exit` is
18
+ // never command completion — the tickmarkr trailer is (C2);
19
+ // - show reports liveness through connected/orphaned only (NO status, NO agent field): agent
20
+ // state lives on orca's own agent-wait/tui-idle surface — `blocked` when show reports
21
+ // agentWait:true, `idle` when the `wait --for tui-idle` condition is satisfied;
22
+ // - the owned title survives at TAB identity: create's title lands on the tab, and the shell
23
+ // overwrites each row's pane title as soon as it draws output (recorded: "…probe…" → "bash"),
24
+ // so recovery matches the owned TAB title in `list --include-visual-layouts`, never a row title;
25
+ // - handles are runtime-scoped; every envelope carries `_meta.runtimeId`, read-only operations
26
+ // recover after observing a change, and sends probe runtime identity before mutating (C1/C4);
27
+ // - `--worktree` takes a SELECTOR, not a path (`orca terminal create --help`, 1.4.186):
28
+ // `path:<abs>` names a checkout outright while `active`/`current` resolve whatever the UI has
29
+ // focused — a driver that lets the app pick has given away the isolation the worktree exists
30
+ // for, so every call this driver makes names `path:` and verifies what came back (T2);
31
+ // - `terminal list`'s `--worktree` is OPTIONAL (same help): the reconcile sweep omits it, because
32
+ // an older run's leftover sits in a checkout this run never knew (T2).
33
+ /** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
34
+ export const ORCA_RESPONSE_FAMILIES = ["status", "create", "list", "read", "send", "wait", "show", "close"];
35
+ export const STALE_HANDLE_CODE = "terminal_handle_stale";
36
+ export const NOT_WRITABLE_CODE = "terminal_not_writable";
37
+ /** The ONLY terminal status that licenses reading a terminal's bytes or its agent state. */
38
+ export const RUNNING_STATUS = "running";
39
+ // The closed method set the terminal-status discipline governs: every one of these validates the
40
+ // terminal record's own status BEFORE it reports anything derived from that terminal — bytes for
41
+ // read/waitOutput, agent state for status/waitAgentStatus. Nothing else in this driver reads a
42
+ // terminal, so the set is closed by construction.
43
+ export const STATUS_GOVERNED_METHODS = ["read", "waitOutput", "status", "waitAgentStatus"];
44
+ const PAGE_LINES = 500; // per-page ask; orca caps server-side and reports `limited`
45
+ const LIST_LIMIT = 10000; // well past orca's own row default; `truncated` still decides (listAll)
46
+ const MAX_PAGES = 400; // runaway guard: a cursor that stops advancing ends the sweep, never loops
47
+ const POLL_MS = 200;
48
+ const SYSTEM_TIME = {
49
+ now: () => Date.now(),
50
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
51
+ };
52
+ /** Every failure this driver produces is explicit and carries the raw bytes that produced it. */
53
+ export class OrcaError extends Error {
54
+ family;
55
+ reason;
56
+ raw;
57
+ code;
58
+ runtimeId;
59
+ constructor(family, reason, raw, opts = {}) {
60
+ super(`orca ${family} failed — ${reason}; raw response:\n${raw}`);
61
+ this.family = family;
62
+ this.reason = reason;
63
+ this.raw = raw;
64
+ this.name = "OrcaError";
65
+ this.code = opts.code;
66
+ this.runtimeId = opts.runtimeId;
67
+ }
68
+ }
69
+ /** The slot cannot be addressed: dead/unknown terminal record, or a handle that cannot be recovered
70
+ * to exactly one owned terminal in the slot's own worktree. Never a silent false or empty string. */
71
+ export class OrcaUnavailableError extends OrcaError {
72
+ terminalStatus;
73
+ constructor(family, reason, raw, terminalStatus) {
74
+ super(family, reason, raw, { code: "terminal_unavailable" });
75
+ this.terminalStatus = terminalStatus;
76
+ this.name = "OrcaUnavailableError";
77
+ }
78
+ }
79
+ function str(v) {
80
+ return typeof v === "string" && v ? v : undefined;
81
+ }
82
+ /**
83
+ * The one JSON seam. Fails CLOSED on every degenerate response — empty, unparseable (a truncated
84
+ * body lands here), non-object, no boolean `ok`, `ok:false`, `ok:true` with no result object, or a
85
+ * successful response without a usable `_meta.runtimeId` —
86
+ * and preserves the raw bytes on the thrown error for diagnostics. Callers never see a partial
87
+ * envelope, so no caller can reinterpret a parse failure as empty output, an unknown-but-successful
88
+ * status, or a successful close.
89
+ */
90
+ export function parseEnvelope(family, stdout, raw) {
91
+ const text = stdout.trim();
92
+ const rawText = raw !== undefined ? raw : stdout;
93
+ if (!text)
94
+ throw new OrcaError(family, "empty response", rawText);
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(text);
98
+ }
99
+ catch (e) {
100
+ throw new OrcaError(family, `unparseable response (${e.message})`, rawText);
101
+ }
102
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
103
+ throw new OrcaError(family, "response is not a JSON object", rawText);
104
+ }
105
+ const env = parsed;
106
+ const meta = env._meta;
107
+ const runtimeId = typeof meta === "object" && meta !== null ? str(meta.runtimeId) : undefined;
108
+ if (typeof env.ok !== "boolean")
109
+ throw new OrcaError(family, "envelope carries no boolean ok", rawText, { runtimeId });
110
+ if (!env.ok) {
111
+ const err = typeof env.error === "object" && env.error !== null ? env.error : {};
112
+ const code = str(err.code);
113
+ throw new OrcaError(family, `refused (${code ?? "no error code"}${str(err.message) ? `: ${str(err.message)}` : ""})`, rawText, { code, runtimeId });
114
+ }
115
+ const result = env.result;
116
+ if (typeof result !== "object" || result === null || Array.isArray(result)) {
117
+ throw new OrcaError(family, "ok response carries no result object", rawText, { runtimeId });
118
+ }
119
+ if (!runtimeId || runtimeId === "none") {
120
+ throw new OrcaError(family, "ok response carries no usable _meta.runtimeId", rawText, { runtimeId });
121
+ }
122
+ return { result: result, runtimeId, raw: rawText };
123
+ }
124
+ function requireTerminal(family, env) {
125
+ const t = env.result.terminal;
126
+ if (typeof t !== "object" || t === null || Array.isArray(t)) {
127
+ throw new OrcaError(family, "response carries no terminal record", env.raw);
128
+ }
129
+ return t;
130
+ }
131
+ /** The worktree a terminal record binds to. The spike pinned the record's shape but not this key's
132
+ * spelling, so the known aliases are accepted and nothing else — a record with none is unbound,
133
+ * which fails every identity comparison below rather than passing one by default. */
134
+ export function terminalWorktree(term) {
135
+ const nested = typeof term.worktree === "object" && term.worktree !== null && !Array.isArray(term.worktree)
136
+ ? str(term.worktree.path)
137
+ : undefined;
138
+ const id = str(term.worktreeId);
139
+ const separator = id?.indexOf("::") ?? -1;
140
+ const fromId = id && separator >= 0 ? str(id.slice(separator + 2)) : undefined;
141
+ return str(term.worktree) ?? str(term.worktreePath) ?? str(term.worktree_path) ?? nested ?? fromId ?? str(term.path) ?? str(term.cwd);
142
+ }
143
+ /**
144
+ * The same checkout under two spellings. git hands tickmarkr one (`/tmp/...` on darwin, or anything
145
+ * below a symlinked parent) while Orca answers the canonicalized one (`/private/tmp/...`), and
146
+ * `resolve()` collapses `..` but never a symlink — so string equality on resolved paths reports two
147
+ * different checkouts and leaves a perfectly valid slot unreacquirable after a runtime restart.
148
+ * Identity is FILESYSTEM identity. A path that does not exist has no filesystem identity to read, so
149
+ * it keeps its resolved spelling: deterministic, and still comparable to another spelling of itself.
150
+ */
151
+ export function canonicalWorktreePath(path) {
152
+ try {
153
+ return realpathSync(resolve(path));
154
+ }
155
+ catch {
156
+ return resolve(path); // never created, already removed, or not ours to stat
157
+ }
158
+ }
159
+ /** Does a record's reported worktree name `canonical` (a path already canonicalized)? An unbound
160
+ * record reports none, and that is never a match — the comparison fails closed, not by default. */
161
+ function sameWorktree(reported, canonical) {
162
+ return reported !== undefined && canonicalWorktreePath(reported) === canonical;
163
+ }
164
+ // Orca has ONE terminal space — no workspace dimension for a terminal to be outside of — so every
165
+ // reconcile candidate takes panesToClose's in-workspace branch: owned-and-undesired closes whichever
166
+ // run (or which daemon) created it, and an unparseable title is never a candidate anywhere.
167
+ const ORCA_SPACE = "orca";
168
+ /** Conservative agent-state mapping over orca's ACTUAL surfaces: `blocked` only when the show
169
+ * record reports agentWait:true, `idle` only when the `terminal wait --for tui-idle` condition is
170
+ * satisfied. The recorded 1.4.186 show response carries NO agent field at all — an absent signal
171
+ * is "unknown", never a fabricated definite status. */
172
+ export function mapAgentState(term, tuiIdle) {
173
+ if (term.agentWait === true)
174
+ return "blocked";
175
+ if (tuiIdle)
176
+ return "idle";
177
+ return "unknown"; // absent fields: unknown, never blocked/idle
178
+ }
179
+ /** Terminal-pane handles under a layout tab node: a pane object, an array of them, or a nested
180
+ * group/split carrying `panes`, `first`, `second`. Only `type:"terminal"` leaves count — anything else is chrome. */
181
+ function collectPaneHandles(node, out) {
182
+ if (Array.isArray(node)) {
183
+ for (const n of node)
184
+ collectPaneHandles(n, out);
185
+ return;
186
+ }
187
+ if (typeof node !== "object" || node === null)
188
+ return;
189
+ const o = node;
190
+ if (o.type === "terminal") {
191
+ const h = str(o.handle);
192
+ if (h)
193
+ out.push(h);
194
+ return;
195
+ }
196
+ collectPaneHandles(o.panes, out);
197
+ collectPaneHandles(o.first, out);
198
+ collectPaneHandles(o.second, out);
199
+ }
200
+ function collectTabs(node, out) {
201
+ if (Array.isArray(node)) {
202
+ for (const n of node)
203
+ collectTabs(n, out);
204
+ return;
205
+ }
206
+ if (typeof node !== "object" || node === null)
207
+ return;
208
+ const o = node;
209
+ if (Array.isArray(o.tabs)) {
210
+ out.push(...o.tabs);
211
+ }
212
+ collectTabs(o.first, out);
213
+ collectTabs(o.second, out);
214
+ }
215
+ /**
216
+ * The renderer hard-wraps long lines, paints margin chrome, and a cursor page boundary splits a
217
+ * marker exactly like a wrap does. `parseWorkerResult` (src/adapters/prompt.ts) already de-wraps
218
+ * trailers this way, so marker matching gets the same joined view beside the raw one.
219
+ * ponytail: joining every line can in principle glue two unrelated lines into a marker — the same
220
+ * tolerance parseWorkerResult has carried since v1.2; raw is matched first, so an unwrapped hit
221
+ * never depends on this.
222
+ */
223
+ export function joinWrapped(raw) {
224
+ return raw.split("\n").map((l) => l.replace(/^[\s│|]+/, "").replace(/[\s│|]+$/, "")).join("");
225
+ }
226
+ export class OrcaDriver {
227
+ id = "orca";
228
+ interactive = true; // a visible terminal the operator can watch and answer
229
+ slots = new Map();
230
+ n = 0;
231
+ bin;
232
+ exec;
233
+ time;
234
+ pageLines;
235
+ pollMs;
236
+ probeStalenessMs;
237
+ constructor(opts = {}) {
238
+ this.bin = opts.bin ?? "orca";
239
+ // Config values flow into a shell here: every argv element is quoted, always.
240
+ this.exec = opts.exec ?? ((args, cwd, timeoutMs) => {
241
+ return sh([this.bin, ...args].map(shq).join(" "), cwd, timeoutMs);
242
+ });
243
+ this.time = opts.time ?? SYSTEM_TIME;
244
+ this.pageLines = opts.pageLines ?? PAGE_LINES;
245
+ this.pollMs = opts.pollMs ?? POLL_MS;
246
+ this.probeStalenessMs = opts.probeStalenessMs ?? 2000;
247
+ }
248
+ async call(family, args, cwd, timeoutMs) {
249
+ let r;
250
+ try {
251
+ r = await this.exec([...args, "--json"], cwd, timeoutMs);
252
+ }
253
+ catch (e) {
254
+ throw new OrcaError(family, `orca CLI could not be invoked (${e.message})`, "");
255
+ }
256
+ // JSON is opt-in on every Orca command. Keep both streams when present so a refusal or a CLI
257
+ // diagnostic is never discarded before the one shared parser reports it.
258
+ const raw = [r.stdout, r.stderr ? `STDERR: ${r.stderr}` : ""].filter(Boolean).join("\n");
259
+ if (r.code !== 0) {
260
+ // Recorded 1.4.186 refusal transport: the process exits rc 1 with the STRUCTURED ok:false
261
+ // body on stdout. Parse it so the refusal CODE survives (terminal_handle_stale,
262
+ // terminal_not_writable) — everything downstream that recovers on a code depends on this
263
+ // branch. The one documented exception is an elapsed `terminal wait`: it exits 1 but carries
264
+ // an ok:true, `wait.satisfied:false` receipt. It remains the shared parser's success path;
265
+ // waitCondition() validates its identity, handle, condition and running status before it
266
+ // becomes the normal `false` result. Any other ok:true body on a nonzero exit stays a
267
+ // transport failure (a shell/runtime crash can leave stale stdout behind).
268
+ try {
269
+ const env = parseEnvelope(family, r.stdout, raw);
270
+ const wait = env.result.wait;
271
+ if (family === "wait"
272
+ && r.code === 1
273
+ && !r.timedOut
274
+ && typeof wait === "object"
275
+ && wait !== null
276
+ && !Array.isArray(wait)
277
+ && wait.satisfied === false) {
278
+ return env;
279
+ }
280
+ }
281
+ catch (e) {
282
+ // The refusal code lives in stdout alone, but the raw bytes propagated to the caller must
283
+ // still be the COMBINED stream — stderr can carry the diagnostic that explains the refusal.
284
+ if (e instanceof OrcaError && !(e instanceof OrcaUnavailableError) && e.code !== undefined) {
285
+ throw new OrcaError(family, e.reason, raw, { code: e.code, runtimeId: e.runtimeId });
286
+ }
287
+ }
288
+ throw new OrcaError(family, `orca CLI exited ${r.code}${r.timedOut ? " after timeout" : ""}`, raw);
289
+ }
290
+ return parseEnvelope(family, r.stdout, raw);
291
+ }
292
+ /** The live runtime's identity, or an explicit failure. A missing or unreachable runtime is a
293
+ * driver-level failure carrying the raw refusal — never a reachable-looking default. */
294
+ async runtimeEnv(cwd) {
295
+ const env = await this.call("status", ["status"], cwd);
296
+ const runtime = env.result.runtime;
297
+ const reachable = typeof runtime === "object" && runtime !== null && !Array.isArray(runtime)
298
+ ? runtime.reachable
299
+ : undefined;
300
+ if (reachable !== true) {
301
+ throw new OrcaError("status", "runtime reports reachable:false or carries no reachability proof", env.raw, { runtimeId: env.runtimeId });
302
+ }
303
+ return env;
304
+ }
305
+ /** Explicit runtime probe. Also T3's doctor probe. */
306
+ async probeRuntime(cwd = process.cwd()) {
307
+ return (await this.runtimeEnv(cwd)).runtimeId;
308
+ }
309
+ // ---- slot lifecycle --------------------------------------------------------------------------
310
+ async slot(cwd, name, opts) {
311
+ const title = opts?.owned ? formatOwnedName(opts.owned) : name;
312
+ const id = `orca-${++this.n}`;
313
+ // ponytail: no terminal is created here — slot() is lazy by contract (T2); the first run()
314
+ // issues the single `terminal create` that carries both the command and this cwd.
315
+ // Canonical: git and Orca can spell one checkout two ways, and every later comparison — create
316
+ // receipt, relist, reconcile — is against THIS value.
317
+ const worktree = canonicalWorktreePath(cwd);
318
+ this.slots.set(id, { title, cwd: worktree, buf: "", recoveries: 0, recovering: false });
319
+ return { id, name: title, cwd: worktree, group: opts?.group };
320
+ }
321
+ /** Where to invoke the CLI for this slot's calls (see OrcaSlotState.dir). */
322
+ cliCwd(st) {
323
+ return st.dir ?? st.cwd;
324
+ }
325
+ state(slot) {
326
+ const s = this.slots.get(slot.id);
327
+ if (!s)
328
+ throw new OrcaError("show", `unknown slot ${slot.id}`, "");
329
+ return s;
330
+ }
331
+ latched(family, st, reason, raw) {
332
+ st.unavailable = reason;
333
+ return new OrcaUnavailableError(family, reason, raw);
334
+ }
335
+ assertAvailable(family, st) {
336
+ if (st.unavailable)
337
+ throw new OrcaUnavailableError(family, st.unavailable, "");
338
+ }
339
+ async run(slot, cmd) {
340
+ const st = this.state(slot);
341
+ if (!st.handle) {
342
+ this.assertAvailable("create", st);
343
+ return this.create(st, cmd);
344
+ }
345
+ this.assertAvailable("send", st);
346
+ // Later deliveries go into the terminal this slot already owns — never a second create.
347
+ // terminalOp proves the runtime binding before the handle goes on the wire.
348
+ const env = await this.terminalOp("send", st, (h) => this.call("send", ["terminal", "send", "--terminal", h, "--text", cmd, "--enter"], this.cliCwd(st)), { mutating: true });
349
+ // Recorded 1.4.186 send receipt: result.send = {handle, accepted, bytesWritten} — there is no
350
+ // `delivered`. `ok:true` alone is not a receipt: only an accepted receipt naming THIS handle
351
+ // proves the text was submitted, so anything else is a failed send, never a silent no-op.
352
+ const receipt = env.result.send;
353
+ if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
354
+ throw new OrcaError("send", "send response carries no send receipt", env.raw);
355
+ }
356
+ const s = receipt;
357
+ if (str(s.handle) !== st.handle) {
358
+ throw new OrcaError("send", `send receipt names terminal ${str(s.handle) ?? "none"}, not the addressed ${st.handle}`, env.raw);
359
+ }
360
+ const expectedBytes = Buffer.byteLength(cmd, "utf8") + 1; // 1 for the --enter newline
361
+ if (s.accepted !== true || typeof s.bytesWritten !== "number" || s.bytesWritten !== expectedBytes) {
362
+ throw new OrcaError("send", `send receipt does not report expected byte delivery (accepted: ${JSON.stringify(s.accepted)}, bytesWritten: ${s.bytesWritten}, expected: ${expectedBytes})`, env.raw);
363
+ }
364
+ }
365
+ async create(st, cmd) {
366
+ await this.probeRuntime(this.cliCwd(st));
367
+ // The selector names THIS slot's checkout outright, and the CLI child is bound to it too, so
368
+ // neither the UI's active worktree (`active`/`current`) nor the daemon's cwd can place it.
369
+ const env = await this.call("create", [
370
+ "terminal", "create", "--worktree", `path:${st.cwd}`, "--title", st.title, "--command", cmd,
371
+ ], this.cliCwd(st));
372
+ const term = requireTerminal("create", env);
373
+ const handle = str(term.handle);
374
+ if (!handle)
375
+ throw new OrcaError("create", "create receipt carries no terminal handle", env.raw);
376
+ // Asking is not getting: the receipt says which checkout the runtime actually resolved, and a
377
+ // terminal in the wrong one has already lost the isolation this run is built on.
378
+ const worktree = terminalWorktree(term);
379
+ if (!sameWorktree(worktree, st.cwd)) {
380
+ // `terminal create` has ALREADY launched the command in that wrong checkout, so refusing the
381
+ // receipt is not yet fail-closed: the agent keeps mutating it. Close the exact handle this
382
+ // receipt named, under the runtime that answered it (closeTerminal re-proves that identity
383
+ // before the handle goes on the wire), then latch — the slot is not addressable again, so a
384
+ // retrying dispatch never opens a second terminal beside one that must not exist. The close
385
+ // is best effort; the latch is what holds if it fails.
386
+ try {
387
+ await this.closeTerminal({ ...st, handle, runtimeId: env.runtimeId });
388
+ }
389
+ catch { /* already gone, or no longer provably ours — never a blind retry */ }
390
+ throw this.latched("create", st, `create receipt bound to ${worktree ?? "no worktree"}, not the slot's ${st.cwd}`, env.raw);
391
+ }
392
+ st.handle = handle;
393
+ // The handle is bound to the runtime identity that ANSWERED its create.
394
+ st.runtimeId = env.runtimeId;
395
+ }
396
+ // ---- handle identity and restart recovery ----------------------------------------------------
397
+ /**
398
+ * Every terminal-addressed call — read AND write — goes through here, and the runtime identity is
399
+ * established BEFORE the runtime-scoped handle goes on the wire. Discarding a lookalike's answer
400
+ * after reading it is still having addressed it, so the probe comes first; the post-call check
401
+ * only closes the narrow race of a restart landing between probe and call. Same for an explicit
402
+ * `terminal_handle_stale`. Either way the driver relists the slot's exact worktree and replaces
403
+ * the handle exactly once, then re-issues the operation against the replacement.
404
+ */
405
+ async terminalOp(family, st, fn, opts) {
406
+ this.assertAvailable(family, st);
407
+ if (!st.handle)
408
+ throw new OrcaError(family, "slot holds no terminal handle yet", "");
409
+ if (!st.runtimeId)
410
+ throw this.latched(family, st, "terminal handle carries no bound runtime identity", "");
411
+ let recovered = false;
412
+ const mutating = opts?.mutating === true;
413
+ // One recovery per operation: a second identity change mid-operation is not another relist, it
414
+ // is an unavailable slot.
415
+ const relist = async (runtimeId, raw) => {
416
+ if (recovered)
417
+ throw this.latched(family, st, `runtime identity changed again during ${family}`, raw);
418
+ recovered = true;
419
+ await this.recover(family, st, runtimeId, raw);
420
+ opts?.onRecovered?.();
421
+ };
422
+ const live = await this.runtimeEnv(this.cliCwd(st));
423
+ let probeTime = this.time.now();
424
+ if (live.runtimeId !== st.runtimeId)
425
+ await relist(live.runtimeId, live.raw);
426
+ for (;;) {
427
+ if (mutating && this.time.now() - probeTime > this.probeStalenessMs) {
428
+ const fresh = await this.runtimeEnv(this.cliCwd(st));
429
+ probeTime = this.time.now();
430
+ if (fresh.runtimeId !== st.runtimeId) {
431
+ await relist(fresh.runtimeId, fresh.raw);
432
+ continue;
433
+ }
434
+ }
435
+ let env;
436
+ try {
437
+ env = await fn(st.handle);
438
+ }
439
+ catch (e) {
440
+ if (e instanceof OrcaError && !(e instanceof OrcaUnavailableError)) {
441
+ // Identity FIRST, before any code-bearing branch: a mutation whose failure carries a
442
+ // different identity — or NO identity at all (a malformed/truncated/_meta-less body the
443
+ // parser rejected before any code existed) — cannot be proven undelivered, so the slot is
444
+ // latched unavailable rather than left addressable for a second, duplicate mutation.
445
+ if (mutating && e.runtimeId !== st.runtimeId) {
446
+ throw this.latched(family, st, `runtime identity mismatch on refused mutation: expected ${st.runtimeId}, got ${e.runtimeId ?? "absent"}`, e.raw);
447
+ }
448
+ if (e.code !== undefined) {
449
+ if (e.runtimeId !== st.runtimeId) {
450
+ await relist(e.runtimeId, e.raw);
451
+ continue;
452
+ }
453
+ if (e.code === STALE_HANDLE_CODE) {
454
+ await relist(e.runtimeId, e.raw);
455
+ continue;
456
+ }
457
+ }
458
+ }
459
+ throw e;
460
+ }
461
+ if (env.runtimeId !== st.runtimeId) {
462
+ if (mutating) {
463
+ throw this.latched(family, st, `runtime identity mismatch on mutation response: expected ${st.runtimeId}, got ${env.runtimeId ?? "absent"}`, env.raw);
464
+ }
465
+ await relist(env.runtimeId, env.raw);
466
+ continue;
467
+ }
468
+ return env;
469
+ }
470
+ }
471
+ async recover(family, st, newRuntimeId, raw) {
472
+ if (st.recovering)
473
+ throw this.latched(family, st, "handle recovery re-entered", raw);
474
+ st.recovering = true;
475
+ try {
476
+ const old = st.handle;
477
+ // visualLayouts is required: the owned title survives at TAB identity only, and rows carry
478
+ // just the shell-controlled pane title (recorded: "…probe…" at create → "bash" on the row).
479
+ const env = await this.call("list", ["terminal", "list", "--worktree", `path:${st.cwd}`, "--include-visual-layouts", "--limit", String(LIST_LIMIT)], this.cliCwd(st));
480
+ const listed = env.result.terminals;
481
+ if (!Array.isArray(listed))
482
+ throw new OrcaError("list", "list response carries no terminals array", env.raw);
483
+ if (env.result.truncated === true)
484
+ throw new OrcaError("list", "terminal list is truncated, cannot safely recover handle", env.raw);
485
+ const layouts = env.result.visualLayouts;
486
+ if (!Array.isArray(layouts))
487
+ throw new OrcaError("list", "list response carries no visualLayouts array — owned tab titles are not recoverable", env.raw);
488
+ // The worktree-authoritative rows of THIS worktree: a handle is adoptable only if a row in
489
+ // the slot's exact worktree backs it. A same-titled tab in another worktree is a lookalike.
490
+ const inWorktree = new Set(listed
491
+ .filter((t) => typeof t === "object" && t !== null && sameWorktree(terminalWorktree(t), st.cwd))
492
+ .map((t) => str(t.handle))
493
+ .filter((h) => h !== undefined));
494
+ // Exactly one tab carrying the FULL owned title, resolving to exactly one terminal pane.
495
+ // Nothing else — a prefix, a suffix, a pane title that happens to spell the owned name, or
496
+ // the same tab title in another worktree is a lookalike and is not this slot's terminal.
497
+ const ownedTabs = [];
498
+ for (const layout of layouts) {
499
+ if (typeof layout !== "object" || layout === null)
500
+ continue;
501
+ const lo = layout;
502
+ if (!sameWorktree(terminalWorktree(lo), st.cwd))
503
+ continue; // another worktree's tabs are never candidates
504
+ const tabs = [];
505
+ collectTabs(lo.root, tabs);
506
+ for (const tab of tabs) {
507
+ if (typeof tab !== "object" || tab === null || str(tab.title) !== st.title)
508
+ continue;
509
+ const handles = [];
510
+ collectPaneHandles(tab.panes, handles);
511
+ ownedTabs.push(handles);
512
+ }
513
+ }
514
+ if (ownedTabs.length === 0) {
515
+ throw this.latched(family, st, `no tab in ${st.cwd} carries the owned title ${st.title} (row titles are shell-controlled and are never ownership keys)`, env.raw);
516
+ }
517
+ if (ownedTabs.length > 1) {
518
+ throw this.latched(family, st, `${ownedTabs.length} tabs in ${st.cwd} carry the owned title ${st.title} — ambiguous`, env.raw);
519
+ }
520
+ const panes = ownedTabs[0].filter((h) => inWorktree.has(h));
521
+ if (panes.length !== 1) {
522
+ throw this.latched(family, st, `the owned tab resolves to ${panes.length} terminals in ${st.cwd}`, env.raw);
523
+ }
524
+ const handle = panes[0];
525
+ if (handle === old) {
526
+ // Handles are runtime-scoped: the same VALUE under a different runtime proves nothing about
527
+ // which terminal it addresses, so it is never adopted.
528
+ throw this.latched(family, st, `replacement handle ${handle} is the old handle value reused by runtime ${newRuntimeId ?? env.runtimeId ?? "unknown"}`, env.raw);
529
+ }
530
+ st.handle = handle;
531
+ // The list response is the identity proof for the replacement. A stale refusal may have been
532
+ // produced by either side of a restart; its metadata never overrides the runtime that relisted.
533
+ st.runtimeId = env.runtimeId;
534
+ st.cursor = undefined; // a different terminal record owns a different cursor space
535
+ // …and a different terminal's bytes. Whatever this slot accumulated came from the runtime we
536
+ // just discarded; keeping it would let an old prefix and a new suffix join into a marker no
537
+ // terminal ever emitted.
538
+ st.buf = "";
539
+ st.recoveries++;
540
+ }
541
+ finally {
542
+ st.recovering = false;
543
+ }
544
+ }
545
+ // ---- status discipline -----------------------------------------------------------------------
546
+ /** Validated READ terminal record, or an explicit unavailable failure. Called BEFORE any caller
547
+ * looks at tail bytes — on every page, on every read-governed method. Read records are the one
548
+ * place orca reports a literal `status` (recorded: "running" live, "exited" on the dead record). */
549
+ validated(family, st, env) {
550
+ const term = requireTerminal(family, env);
551
+ const handle = str(term.handle);
552
+ if (handle !== st.handle) {
553
+ throw new OrcaUnavailableError(family, `terminal record names ${handle ?? "no handle"}, not the addressed ${st.handle}`, env.raw);
554
+ }
555
+ const status = str(term.status);
556
+ if (status !== RUNNING_STATUS) {
557
+ throw new OrcaUnavailableError(family, `terminal ${str(term.handle) ?? st.handle ?? "?"} reports status ${status ?? "absent"}, not ${RUNNING_STATUS}`, env.raw, status);
558
+ }
559
+ return term;
560
+ }
561
+ /** Validated SHOW terminal record, or an explicit unavailable failure. The recorded 1.4.186 show
562
+ * response reports liveness through connected/orphaned and carries NO status and NO agent field,
563
+ * so this is the status discipline's show leg: a terminal that cannot prove connected-and-not-
564
+ * orphaned is unavailable for state questions, exactly as a non-running read record is for bytes. */
565
+ liveShowTerm(family, st, env) {
566
+ const term = requireTerminal(family, env);
567
+ const handle = str(term.handle);
568
+ if (handle !== st.handle) {
569
+ throw new OrcaUnavailableError(family, `terminal record names ${handle ?? "no handle"}, not the addressed ${st.handle}`, env.raw);
570
+ }
571
+ if (term.connected !== true || term.orphaned === true) {
572
+ const status = term.orphaned === true ? "orphaned" : term.connected === false ? "disconnected" : "unknown";
573
+ throw new OrcaUnavailableError(family, `terminal ${str(term.handle) ?? st.handle ?? "?"} reports connected=${JSON.stringify(term.connected)}, orphaned=${JSON.stringify(term.orphaned)}`, env.raw, status);
574
+ }
575
+ return term;
576
+ }
577
+ tailText(family, term, raw) {
578
+ const tail = term.tail;
579
+ if (!Array.isArray(tail))
580
+ throw new OrcaError(family, "terminal record carries no tail array", raw);
581
+ if (!tail.every((line) => typeof line === "string")) {
582
+ throw new OrcaError(family, "terminal tail carries a non-string line", raw);
583
+ }
584
+ return tail.join("\n");
585
+ }
586
+ async readPage(family, st, cursor, lines) {
587
+ let recovered = false;
588
+ const env = await this.terminalOp(family, st, (h) => this.call("read", [
589
+ "terminal", "read", "--terminal", h,
590
+ ...(cursor === undefined || recovered ? [] : ["--cursor", cursor]),
591
+ "--limit", String(lines),
592
+ ], this.cliCwd(st)), { onRecovered: () => { recovered = true; } });
593
+ return { term: this.validated(family, st, env), raw: env.raw, recovered };
594
+ }
595
+ /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
596
+ * across cursor pages are not reassembled here; that is waitOutput's job. */
597
+ async read(slot, lines) {
598
+ if (!Number.isInteger(lines) || lines <= 0)
599
+ throw new OrcaError("read", `invalid line limit ${lines}`, "");
600
+ const st = this.state(slot);
601
+ const { term, raw } = await this.readPage("read", st, undefined, lines);
602
+ return this.tailText("read", term, raw);
603
+ }
604
+ /**
605
+ * Bounded cursor-paged sweep into the slot's accumulated buffer. The first read of a slot carries
606
+ * no cursor: it is the ANCHOR, whose `oldestCursor` says where the retained buffer starts (its own
607
+ * tail is the newest lines, not the oldest, so it is not appended). Every page after it appends,
608
+ * and every one of them — anchor included — is status-validated before a single byte is matched.
609
+ */
610
+ async sweep(st) {
611
+ let cursor = st.cursor;
612
+ let anchored = cursor !== undefined;
613
+ for (let page = 0; page < MAX_PAGES; page++) {
614
+ const { term, raw, recovered } = await this.readPage("waitOutput", st, cursor, this.pageLines);
615
+ if (recovered) {
616
+ // recover() reset the slot cursor; reset this in-flight sweep too. The retried read was
617
+ // deliberately rebuilt without the old cursor and is the replacement record's new anchor.
618
+ cursor = undefined;
619
+ anchored = false;
620
+ }
621
+ if (!anchored) {
622
+ const oldest = str(term.oldestCursor);
623
+ anchored = true;
624
+ if (oldest === undefined) {
625
+ // No cursors on this record: one shot is all there is.
626
+ st.buf = (st.buf + this.tailText("waitOutput", term, raw) + "\n").slice(-MAX_BUF);
627
+ return st.buf;
628
+ }
629
+ cursor = oldest;
630
+ continue;
631
+ }
632
+ st.buf = (st.buf + this.tailText("waitOutput", term, raw) + "\n").slice(-MAX_BUF);
633
+ const next = str(term.nextCursor);
634
+ const latest = str(term.latestCursor);
635
+ if (term.limited !== true || next === latest) {
636
+ st.cursor = next ?? cursor;
637
+ return st.buf;
638
+ }
639
+ if (next === undefined || next === cursor) {
640
+ throw new OrcaError("read", "limited cursor page did not advance", raw);
641
+ }
642
+ cursor = next;
643
+ }
644
+ throw new OrcaUnavailableError("waitOutput", `cursor paging exceeded ${MAX_PAGES} pages`, "");
645
+ }
646
+ async waitOutput(slot, pattern, timeoutMs, opts) {
647
+ const st = this.state(slot);
648
+ const re = opts?.regex ? new RegExp(pattern) : null; // compile once, not per poll
649
+ const hit = (buf) => {
650
+ const joined = joinWrapped(buf);
651
+ return re ? re.test(buf) || re.test(joined) : buf.includes(pattern) || joined.includes(pattern);
652
+ };
653
+ const deadline = this.time.now() + timeoutMs;
654
+ for (;;) {
655
+ if (hit(await this.sweep(st)))
656
+ return true;
657
+ const left = deadline - this.time.now();
658
+ if (left <= 0)
659
+ return false;
660
+ await this.time.sleep(Math.min(this.pollMs, left));
661
+ }
662
+ }
663
+ async status(slot) {
664
+ const st = this.state(slot);
665
+ for (;;) {
666
+ const gen = `${st.runtimeId}:${st.handle}:${st.recoveries}`;
667
+ // The status discipline's read leg: an exited/unknown READ status must block agent-state
668
+ // reporting even when the show record alone would still look connected (show carries no
669
+ // status field of its own, so a terminal can report "unknown"/"exited" on read while its
670
+ // show row still says connected — the read leg is the only place that catches that).
671
+ await this.readPage("status", st, undefined, 1);
672
+ if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== gen) {
673
+ continue;
674
+ }
675
+ const env = await this.terminalOp("show", st, (h) => this.call("show", ["terminal", "show", "--terminal", h], this.cliCwd(st)));
676
+ if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== gen) {
677
+ continue;
678
+ }
679
+ const term = this.liveShowTerm("status", st, env);
680
+ if (term.agentWait === true)
681
+ return "blocked";
682
+ // `idle` is proven only by orca's own tui-idle condition: a 1ms wait is a point-in-time probe —
683
+ // satisfied now → idle; elapsed (the recorded `timeout` refusal) → not idle.
684
+ const isIdle = await this.waitCondition(st, "tui-idle", 1);
685
+ if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== gen) {
686
+ continue;
687
+ }
688
+ return mapAgentState(term, isIdle);
689
+ }
690
+ }
691
+ /** One `terminal wait` through the full identity machinery. The recorded 1.4.186 elapsed answer
692
+ * is rc 1 + ok:true + {handle, condition, satisfied:false, status:"running"}; it is "not yet"
693
+ * only after this method validates all four fields. Any malformed/refused wait remains explicit. */
694
+ async waitCondition(st, condition, budgetMs) {
695
+ const env = await this.terminalOp("wait", st, (h) => this.call("wait", [
696
+ "terminal", "wait", "--terminal", h, "--for", condition, "--timeout-ms", String(Math.max(1, Math.floor(budgetMs))),
697
+ ], this.cliCwd(st), budgetMs + 15_000));
698
+ const receipt = env.result.wait;
699
+ if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
700
+ throw new OrcaError("wait", "wait response carries no wait receipt", env.raw);
701
+ }
702
+ const w = receipt;
703
+ if (str(w.handle) !== st.handle || str(w.condition) !== condition) {
704
+ throw new OrcaError("wait", `wait receipt does not name ${condition} for ${st.handle}`, env.raw);
705
+ }
706
+ const rStatus = str(w.status);
707
+ if (w.satisfied === false) {
708
+ if (rStatus !== RUNNING_STATUS) {
709
+ throw new OrcaUnavailableError("wait", `terminal ${st.handle} reports status ${rStatus ?? "absent"}, not ${RUNNING_STATUS} in elapsed wait receipt`, env.raw, rStatus);
710
+ }
711
+ return false;
712
+ }
713
+ if (w.satisfied !== true) {
714
+ throw new OrcaError("wait", `wait receipt does not prove ${condition} is satisfied:true for ${st.handle}`, env.raw);
715
+ }
716
+ if (condition === "tui-idle" && rStatus !== RUNNING_STATUS) {
717
+ throw new OrcaUnavailableError("wait", `terminal ${st.handle} reports status ${rStatus ?? "absent"}, not ${RUNNING_STATUS} in wait receipt`, env.raw, rStatus);
718
+ }
719
+ return true;
720
+ }
721
+ async waitAgentStatus(slot, status, timeoutMs) {
722
+ const st = this.state(slot);
723
+ const deadline = this.time.now() + timeoutMs;
724
+ for (;;) {
725
+ const genBefore = `${st.runtimeId}:${st.handle}:${st.recoveries}`;
726
+ const now = await this.status(slot); // liveness- and identity-validated on every poll
727
+ if (now === status)
728
+ return true;
729
+ if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== genBefore) {
730
+ continue;
731
+ }
732
+ const left = deadline - this.time.now();
733
+ if (left <= 0)
734
+ return false;
735
+ // "done" is terminal EXIT and "idle" is the tui-idle condition — the two things
736
+ // `terminal wait` truthfully answers, each with the remaining budget in one call. "done" is
737
+ // never command completion: `--command` runs inside a wrapper shell that outlives it (C2).
738
+ if (status === "done" || status === "idle") {
739
+ const cond = status === "done" ? "exit" : "tui-idle";
740
+ const satisfied = await this.waitCondition(st, cond, left);
741
+ if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== genBefore) {
742
+ continue;
743
+ }
744
+ if (satisfied)
745
+ return true;
746
+ }
747
+ const left2 = deadline - this.time.now();
748
+ if (left2 <= 0)
749
+ return false;
750
+ await this.time.sleep(Math.min(this.pollMs, left2));
751
+ }
752
+ }
753
+ async notify(msg, opts) {
754
+ if (opts?.tier === "routine")
755
+ return;
756
+ console.log(`[tickmarkr] ${msg}`); // console fallback only — notification injection is out of scope
757
+ }
758
+ async close(slot) {
759
+ const st = this.slots.get(slot.id);
760
+ if (!st)
761
+ return;
762
+ if (st.handle)
763
+ await this.closeTerminal(st);
764
+ this.slots.delete(slot.id);
765
+ }
766
+ /**
767
+ * The one destructive call in this driver, for a slot's own terminal AND for a reconcile candidate
768
+ * alike. It goes through terminalOp deliberately: the live runtime identity is proven immediately
769
+ * BEFORE the handle goes on the wire, and a runtime that changed does not merely fail the close —
770
+ * the handle is DISCARDED and re-derived from the owned tab title in that exact checkout, where a
771
+ * handle value the new runtime happened to reissue to somebody else's terminal is refused by
772
+ * construction (recover()). Checking identity on the receipt afterwards could not undo a close.
773
+ */
774
+ async closeTerminal(st) {
775
+ this.assertAvailable("close", st);
776
+ const env = await this.terminalOp("close", st, (h) => this.call("close", ["terminal", "close", "--terminal", h], this.cliCwd(st)), { mutating: true });
777
+ // A handle-bound close receipt proves this terminal was removed. `ptyKilled` says whether a live
778
+ // pty needed killing, not whether the terminal closed: Orca 1.4.186 returns false for an
779
+ // already-exited/no-PTY leaf while still removing its terminal record.
780
+ const receipt = env.result.close;
781
+ if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
782
+ throw new OrcaError("close", "close response carries no close receipt", env.raw);
783
+ }
784
+ const r = receipt;
785
+ if (str(r.handle) !== st.handle || (r.ptyKilled !== true && r.ptyKilled !== false)) {
786
+ throw new OrcaError("close", `close receipt does not prove terminal ${st.handle} was closed`, env.raw);
787
+ }
788
+ }
789
+ /**
790
+ * The WHOLE terminal table. `terminal list` caps rows at its own default and says so through
791
+ * `truncated`/`totalCount`; a capped listing is not an ownership snapshot, because the row it
792
+ * dropped is precisely the older run's leftover no later sweep would ever see again.
793
+ * ponytail: two asks, not a paging loop — `--limit` takes the whole table in one go, and a runtime
794
+ * that still reports truncated at totalCount rows is a listing this sweep declines to judge on.
795
+ */
796
+ async listAll(from) {
797
+ const argv = (limit) => ["terminal", "list", "--include-visual-layouts", "--limit", String(limit)];
798
+ const first = await this.call("list", argv(LIST_LIMIT), from);
799
+ if (first.result.truncated !== true)
800
+ return first;
801
+ const total = typeof first.result.totalCount === "number" ? first.result.totalCount : 0;
802
+ const whole = await this.call("list", argv(Math.max(total, LIST_LIMIT + 1)), from);
803
+ if (whole.result.truncated === true)
804
+ throw new OrcaError("list", "terminal list is still truncated at totalCount rows", whole.raw);
805
+ return whole;
806
+ }
807
+ /**
808
+ * Sweep tickmarkr-owned terminals down to `desired`. Ownership is decided ONLY by parseOwnedName
809
+ * over the owned TAB title, through the same panesToClose fold herdr uses (drivers/types.ts): an
810
+ * owned-and-undesired terminal closes whichever run — and whichever daemon — created it, and a
811
+ * title that does not parse is never a candidate however much it resembles one.
812
+ *
813
+ * The listing is UNSCOPED and layout-bearing. Unscoped because an older run's leftover sits in a
814
+ * checkout this run never knew, so a `--worktree`-filtered sweep is exactly how such a leftover
815
+ * survives forever. Layout-bearing because the owned title survives at TAB identity only — a list
816
+ * row's `title` is the shell-controlled pane title, and closing on that is how a foreign pane that
817
+ * happens to be running an owned-looking command gets killed.
818
+ *
819
+ * Cosmetic by contract: every failure is swallowed, per candidate and overall.
820
+ */
821
+ async reconcile(desired, runId, opts) {
822
+ try {
823
+ // Every call below is handle-addressed or explicitly selectored, so the CLI's own cwd selects
824
+ // nothing — it only has to exist, which the checkouts being swept no longer need to.
825
+ const from = process.cwd();
826
+ const env = await this.listAll(from);
827
+ const layouts = env.result.visualLayouts;
828
+ if (!Array.isArray(layouts))
829
+ return; // no tab titles, no ownership evidence, nothing to close
830
+ const candidates = new Map();
831
+ for (const layout of layouts) {
832
+ if (typeof layout !== "object" || layout === null)
833
+ continue;
834
+ const lo = layout;
835
+ const worktree = terminalWorktree(lo);
836
+ if (!worktree)
837
+ continue; // an unbound layout names no checkout to re-acquire a handle in
838
+ const tabs = [];
839
+ collectTabs(lo.root, tabs);
840
+ for (const tab of tabs) {
841
+ if (typeof tab !== "object" || tab === null)
842
+ continue;
843
+ const t = tab;
844
+ const title = str(t.title);
845
+ if (!title)
846
+ continue;
847
+ const handles = [];
848
+ collectPaneHandles(t.panes, handles); // a split tab holds more than one, and both are ours
849
+ for (const handle of handles)
850
+ candidates.set(handle, { title, worktree: canonicalWorktreePath(worktree) });
851
+ }
852
+ }
853
+ const toClose = panesToClose([...candidates].map(([paneId, c]) => ({ name: c.title, paneId, workspaceId: ORCA_SPACE })), desired, ORCA_SPACE, runId, opts);
854
+ for (const c of toClose) {
855
+ const cand = candidates.get(c.paneId);
856
+ if (!cand)
857
+ continue;
858
+ try {
859
+ await this.closeTerminal({
860
+ title: cand.title,
861
+ cwd: cand.worktree,
862
+ dir: from,
863
+ handle: c.paneId,
864
+ runtimeId: env.runtimeId, // the identity that vouched for this handle, checked before the close
865
+ buf: "",
866
+ recoveries: 0,
867
+ recovering: false,
868
+ });
869
+ }
870
+ catch { /* vanished, stale, or no longer provably ours — never a blind retry */ }
871
+ }
872
+ }
873
+ catch { /* cosmetic — visibility hygiene never fails the run */ }
874
+ }
875
+ // tickmarkr's own createWorktree stays the sole checkout authority — orca never makes worktrees.
876
+ worktree(repo, branch, baseRef) {
877
+ return createWorktree(repo, branch, baseRef);
878
+ }
879
+ }