tickmarkr 2.5.4 → 2.5.6

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 (78) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/registry.js +6 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/approve.d.ts +1 -0
  5. package/dist/cli/commands/approve.js +118 -9
  6. package/dist/cli/commands/doctor.d.ts +10 -0
  7. package/dist/cli/commands/doctor.js +44 -29
  8. package/dist/cli/commands/fleet.js +199 -33
  9. package/dist/cli/commands/init.js +196 -6
  10. package/dist/cli/commands/plan.js +20 -4
  11. package/dist/cli/commands/resume.js +4 -2
  12. package/dist/cli/commands/run.js +11 -2
  13. package/dist/cli/commands/verify.js +102 -81
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +3 -1
  16. package/dist/config/config.d.ts +25 -8
  17. package/dist/config/config.js +41 -29
  18. package/dist/config/fleet-overlay.d.ts +3 -9
  19. package/dist/config/fleet-overlay.js +114 -19
  20. package/dist/config/fleet-why.d.ts +7 -0
  21. package/dist/config/fleet-why.js +5 -0
  22. package/dist/drivers/index.d.ts +15 -1
  23. package/dist/drivers/index.js +39 -10
  24. package/dist/drivers/orca.d.ts +119 -10
  25. package/dist/drivers/orca.js +781 -110
  26. package/dist/gates/baseline.d.ts +17 -3
  27. package/dist/gates/baseline.js +63 -15
  28. package/dist/gates/cache.d.ts +101 -0
  29. package/dist/gates/cache.js +401 -0
  30. package/dist/gates/llm.d.ts +3 -0
  31. package/dist/gates/llm.js +11 -0
  32. package/dist/gates/review.d.ts +28 -3
  33. package/dist/gates/review.js +106 -14
  34. package/dist/gates/run-gates.d.ts +9 -1
  35. package/dist/gates/run-gates.js +407 -102
  36. package/dist/gates/test-manifest.d.ts +128 -0
  37. package/dist/gates/test-manifest.js +463 -0
  38. package/dist/gates/test-reporter.d.ts +4 -0
  39. package/dist/gates/test-reporter.js +57 -0
  40. package/dist/graph/graph.d.ts +2 -0
  41. package/dist/graph/graph.js +45 -1
  42. package/dist/route/preference.d.ts +22 -1
  43. package/dist/route/preference.js +123 -25
  44. package/dist/route/router.js +31 -6
  45. package/dist/run/consult.js +5 -4
  46. package/dist/run/daemon.d.ts +15 -0
  47. package/dist/run/daemon.js +638 -167
  48. package/dist/run/execution-budget.d.ts +25 -0
  49. package/dist/run/execution-budget.js +142 -0
  50. package/dist/run/git.d.ts +50 -1
  51. package/dist/run/git.js +131 -12
  52. package/dist/run/journal.d.ts +16 -2
  53. package/dist/run/journal.js +115 -25
  54. package/dist/run/lease.d.ts +58 -0
  55. package/dist/run/lease.js +310 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/run/recovery.d.ts +8 -0
  61. package/dist/run/recovery.js +25 -0
  62. package/dist/run/repair-selection.d.ts +12 -0
  63. package/dist/run/repair-selection.js +56 -0
  64. package/dist/run/stall.d.ts +6 -1
  65. package/dist/run/stall.js +60 -3
  66. package/dist/tui/cockpit/board.d.ts +96 -0
  67. package/dist/tui/cockpit/board.js +346 -0
  68. package/dist/tui/cockpit/decision-actions.js +2 -0
  69. package/dist/tui/cockpit/layout.d.ts +5 -1
  70. package/dist/tui/cockpit/layout.js +8 -3
  71. package/dist/tui/cockpit/live-runtime.js +71 -21
  72. package/dist/tui/cockpit/run-view.d.ts +7 -5
  73. package/dist/tui/cockpit/run-view.js +12 -11
  74. package/dist/tui/ink/fleet-app.d.ts +49 -27
  75. package/dist/tui/ink/fleet-app.js +229 -38
  76. package/package.json +2 -2
  77. package/skills/tickmarkr-overseer/SKILL.md +173 -113
  78. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -1,8 +1,11 @@
1
- import { realpathSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join, resolve } from "node:path";
3
4
  import { shq } from "../adapters/types.js";
4
5
  import { createWorktree, sh } from "../run/git.js";
5
- import { Journal } from "../run/journal.js";
6
+ import { Journal, parseRunId } from "../run/journal.js";
7
+ import { stateDirName } from "../graph/graph.js";
8
+ import { readWatchBoard, requestWatchBoardStop, stopWatchBoard, WATCH_OWNER_ENV } from "../run/supervision.js";
6
9
  import { MAX_BUF } from "./subprocess.js";
7
10
  import { formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
8
11
  // Orca (onorca.dev) as a third execution surface beside herdr and subprocess. tickmarkr keeps
@@ -30,11 +33,16 @@ import { formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
30
33
  // focused — a driver that lets the app pick has given away the isolation the worktree exists
31
34
  // for, so every call this driver makes names `path:` and verifies what came back (T2);
32
35
  // - `terminal list`'s `--worktree` is OPTIONAL (same help): the reconcile sweep omits it, because
33
- // an older run's leftover sits in a checkout this run never knew (T2).
36
+ // an older run's leftover sits in a checkout this run never knew (T2);
37
+ // - Orca tracks only the worktrees it created or the operator opened (1.4.200, OBS-1004): a task
38
+ // checkout the daemon adds under the clone is not a selector, `worktree current` from inside it
39
+ // answers the enclosing clone, and there is no adopt verb — so every terminal is created ON the
40
+ // tracked worktree with its command cd'ed into the checkout, and `worktree set` names the
41
+ // tracked worktree; identity stays the handle plus the owned tab title.
34
42
  /** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
35
43
  export const ORCA_RESPONSE_FAMILIES = [
36
44
  "status", "create", "list", "read", "send", "wait", "show", "close",
37
- "worktree-current", "worktree-set", "hooks-status",
45
+ "worktree-current", "worktree-set", "hooks-status", "split",
38
46
  ];
39
47
  export const ORCA_FIXTURE_VERSION = "1.4.195";
40
48
  export const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
@@ -54,9 +62,6 @@ const PAGE_LINES = 500; // per-page ask; orca caps server-side and reports `limi
54
62
  const LIST_LIMIT = 10000; // well past orca's own row default; `truncated` still decides (listAll)
55
63
  const MAX_PAGES = 400; // runaway guard: a cursor that stops advancing ends the sweep, never loops
56
64
  const POLL_MS = 200;
57
- export const WORKTREE_ADOPTION_TIMEOUT_MS = 60_000;
58
- const WORKTREE_ADOPTION_POLL_MS = 1_000;
59
- const WORKTREE_ADOPTION_JOURNAL_MS = 2_000;
60
65
  const NUDGE_ECHO_TIMEOUT_MS = 2_000;
61
66
  /** A missing slot gets the same bounded chance to appear as a reaped shell gets to settle. */
62
67
  export const PENDING_PROJECT_GRACE_MS = 2_000;
@@ -189,6 +194,67 @@ export function canonicalWorktreePath(path) {
189
194
  function sameWorktree(reported, canonical) {
190
195
  return reported !== undefined && canonicalWorktreePath(reported) === canonical;
191
196
  }
197
+ /** OBS-1004: a reported (tracked) worktree encloses a checkout when it IS the checkout or a parent of it. */
198
+ function enclosesCheckout(reported, checkout) {
199
+ if (reported === undefined)
200
+ return false;
201
+ const tracked = canonicalWorktreePath(reported);
202
+ return tracked === checkout || checkout.startsWith(`${tracked}/`);
203
+ }
204
+ /** The proof line a worker terminal prints first: recovery and focus read it back (FX-N01). */
205
+ export const CHECKOUT_MARK = "TICKMARKR_CHECKOUT";
206
+ // FX-N05: the proof is a FRAMED value — `TICKMARKR_CHECKOUT <byteLength>:<utf8 bytes as hex>;` — so it
207
+ // carries no whitespace or quotes, survives renderer wrapping (hex rows re-join losslessly), and is
208
+ // either complete (length matches, terminator present) or nothing. A prefix of another checkout can
209
+ // never decode to this one; `A` versus `A B`, `…--T1` versus `…--T10` are different frames.
210
+ const CHECKOUT_FRAME_RE = /TICKMARKR_CHECKOUT (\d+):([0-9a-f]*)(;?)/g;
211
+ const PROOF_PAGES = 16; // pages read from the oldest cursor before the proof is declared absent
212
+ /** The exact bytes the create command prints as its first line. */
213
+ export function checkoutProofLine(checkout) {
214
+ const bytes = Buffer.from(checkout, "utf8");
215
+ return `${CHECKOUT_MARK} ${bytes.length}:${bytes.toString("hex")};`;
216
+ }
217
+ /** Every complete frame in a scrollback, decoded and canonicalized; whether an incomplete one was seen. */
218
+ export function checkoutFrames(text) {
219
+ const complete = [];
220
+ let incomplete = false;
221
+ // Rows are re-joined first: a wrapped frame is whole again, and joining can never complete a
222
+ // frame that was not printed whole — the declared length and the terminator decide.
223
+ for (const m of joinWrapped(text).matchAll(CHECKOUT_FRAME_RE)) {
224
+ const length = Number(m[1]);
225
+ const hex = m[2];
226
+ if (m[3] !== ";" || hex.length !== length * 2 || !Number.isInteger(length)) {
227
+ incomplete = true;
228
+ continue;
229
+ }
230
+ complete.push(canonicalWorktreePath(Buffer.from(hex, "hex").toString("utf8")));
231
+ }
232
+ return { complete, incomplete };
233
+ }
234
+ /** Does a scrollback prove exactly `checkout`: at least one complete frame equals it, no complete
235
+ * frame names anything else, and no frame is incomplete. Full-path equality after canonicalization —
236
+ * never a prefix, a substring, or a whitespace-terminated fragment. */
237
+ export function provesCheckout(text, checkout) {
238
+ const { complete, incomplete } = checkoutFrames(text);
239
+ return !incomplete && complete.includes(checkout) && complete.every((c) => c === checkout);
240
+ }
241
+ /**
242
+ * Everything a terminal on the tracked worktree runs before the payload: enter the checkout (a
243
+ * failed cd stops the whole line — nothing of the payload ever runs in the enclosing path), print
244
+ * the proof line, then hand the WHOLE payload to one `sh -c` so a background list, a `;` list or a
245
+ * subshell inside it all start in the checkout and its exit status is the payload's (FX-N02).
246
+ */
247
+ export function checkoutPrefix(checkout) {
248
+ return `cd ${shq(checkout)} && printf '%s\\n' ${shq(checkoutProofLine(checkout))} && sh -c `;
249
+ }
250
+ /** The command a terminal on the tracked worktree runs so that it executes INSIDE the checkout. */
251
+ export function inCheckout(checkout, cmd) {
252
+ return `${checkoutPrefix(checkout)}${shq(cmd)}`;
253
+ }
254
+ /** Every checkout the complete proof frames in a scrollback name, in order of appearance. */
255
+ export function checkoutsNamed(text) {
256
+ return checkoutFrames(text).complete;
257
+ }
192
258
  // Orca has ONE terminal space — no workspace dimension for a terminal to be outside of — so every
193
259
  // reconcile candidate takes panesToClose's in-workspace branch: owned-and-undesired closes whichever
194
260
  // run (or which daemon) created it, and an unparseable title is never a candidate anywhere.
@@ -204,12 +270,243 @@ export function mapAgentState(term, tuiIdle) {
204
270
  return "idle";
205
271
  return "unknown"; // absent fields: unknown, never blocked/idle
206
272
  }
273
+ function boardState(r) {
274
+ if (r.retired === true)
275
+ return "retired";
276
+ if (typeof r.pid !== "number" || typeof r.armId !== "string")
277
+ return "reserved";
278
+ return r.pane === "" ? "claimed" : "bound";
279
+ }
280
+ /** The record's exact bytes and their parse; undefined only when no record exists. A torn or foreign
281
+ * file throws — it is indeterminate, never absent. */
282
+ function readBoard(path, repo, runId) {
283
+ let raw;
284
+ try {
285
+ raw = readFileSync(path, "utf8");
286
+ }
287
+ catch (error) {
288
+ if (error.code === "ENOENT")
289
+ return undefined;
290
+ throw error;
291
+ }
292
+ const record = JSON.parse(raw);
293
+ if (record.repo !== realpathSync(repo) || record.runId !== runId || typeof record.token !== "string" || typeof record.pane !== "string") {
294
+ throw new Error(`watch owner record ${path} does not describe run ${runId} in ${repo}`);
295
+ }
296
+ return { raw, record };
297
+ }
298
+ /**
299
+ * Whole-record compare-and-swap. The canonical path stays readable until commit: a mkdir lock
300
+ * excludes other writers, then create-only `link`s the new inode (fails if anything exists) or
301
+ * `rename`s the new file over the live path (POSIX atomic replace — readers see old or new, never
302
+ * absence). A crash that leaves a `.tmp` or `.lock` does not drop the previous record.
303
+ * Each lock generation publishes its pid and nonce atomically in a symlink target. A holder that died
304
+ * between taking and releasing it is recovered at once (its lock is taken over in place); a live
305
+ * holder bounds the wait on injected time and is then refused with the record untouched. A legacy
306
+ * pid-less or malformed generation is recoverable too: a contender atomically creates the next owner
307
+ * generation, and release removes the directory only while that exact generation is still current.
308
+ * ponytail: observeNamedRun (supervision.ts) renames without CAS. It cannot interleave with a swap
309
+ * because the narrator writes nothing between reserve and claim, and no transition here swaps one.
310
+ */
311
+ export async function casBoard(family, path, expected, next, time = SYSTEM_TIME) {
312
+ const raw = JSON.stringify(next) + "\n";
313
+ const refused = () => new OrcaError(family, `watch owner record ${path} changed underneath; swap refused and the current record kept`, "");
314
+ mkdirSync(dirname(path), { recursive: true });
315
+ const lock = `${path}.lock`;
316
+ const deadline = time.now() + LOCK_WAIT_MS;
317
+ const ownerTarget = `${process.pid}:${randomUUID()}`;
318
+ let ownerEntry = "";
319
+ let acquiredEntries = [];
320
+ for (;;) {
321
+ try {
322
+ mkdirSync(lock);
323
+ }
324
+ catch (error) {
325
+ if (error.code !== "EEXIST")
326
+ throw error;
327
+ }
328
+ // The marker's directory entry and target appear as one filesystem operation. If the creator
329
+ // dies after mkdir but before this call, a contender claims owner.0; if both race, only one
330
+ // symlink wins and the loser judges that published owner before entering the CAS section.
331
+ const holder = lockHolder(lock);
332
+ if (holder?.pid !== undefined && pidLive(holder.pid)) {
333
+ if (time.now() >= deadline)
334
+ throw new OrcaError(family, `watch owner record ${path} lock not acquired; current record kept`, "");
335
+ await time.sleep(10);
336
+ continue;
337
+ }
338
+ ownerEntry = `owner.${(holder?.gen ?? -1) + 1}`;
339
+ try {
340
+ symlinkSync(ownerTarget, join(lock, ownerEntry));
341
+ // Snapshot only generations that this takeover superseded, with our marker last. Release must
342
+ // never recursively scan: after our final unlink a contender may publish a replacement marker
343
+ // before rmdir, and that marker belongs to the contender, not to this generation.
344
+ acquiredEntries = readdirSync(lock)
345
+ .filter((entry) => entry === ownerEntry || lockEntryGeneration(entry) !== undefined)
346
+ .sort((a, b) => Number(a === ownerEntry) - Number(b === ownerEntry));
347
+ break;
348
+ }
349
+ catch (error) {
350
+ const code = error.code;
351
+ if (code !== "EEXIST" && code !== "ENOENT")
352
+ throw error;
353
+ continue; // another contender published this generation, or the prior holder just released
354
+ }
355
+ }
356
+ try {
357
+ let current;
358
+ try {
359
+ current = readFileSync(path, "utf8");
360
+ }
361
+ catch (error) {
362
+ if (error.code !== "ENOENT")
363
+ throw error;
364
+ }
365
+ if (current !== expected)
366
+ throw refused();
367
+ const tmp = `${path}.${randomUUID()}.tmp`;
368
+ writeFileSync(tmp, raw);
369
+ try {
370
+ if (expected === undefined) {
371
+ try {
372
+ linkSync(tmp, path);
373
+ }
374
+ catch {
375
+ throw refused();
376
+ }
377
+ }
378
+ else {
379
+ renameSync(tmp, path);
380
+ }
381
+ }
382
+ finally {
383
+ rmSync(tmp, { force: true });
384
+ }
385
+ return raw;
386
+ }
387
+ finally {
388
+ releaseBoardLock(lock, ownerEntry, ownerTarget, acquiredEntries);
389
+ }
390
+ }
391
+ const LOCK_WAIT_MS = 2_000;
392
+ /** Best-effort release cannot invalidate a committed CAS. Superseded entries go first and our marker
393
+ * goes last; after that unlink, a non-recursive rmdir either removes the empty old generation or
394
+ * leaves a contender's newly published marker untouched. Any abnormal filesystem error leaves the
395
+ * lock for the next generation's stale-owner recovery instead of escaping from the caller's finally. */
396
+ function releaseBoardLock(lock, ownerEntry, ownerTarget, acquiredEntries) {
397
+ const holder = lockHolder(lock);
398
+ if (holder?.entry !== ownerEntry || holder.target !== ownerTarget)
399
+ return;
400
+ for (const entry of acquiredEntries) {
401
+ try {
402
+ unlinkSync(join(lock, entry));
403
+ }
404
+ catch (error) {
405
+ if (error.code !== "ENOENT")
406
+ return;
407
+ }
408
+ }
409
+ try {
410
+ rmdirSync(lock);
411
+ }
412
+ catch { /* ENOTEMPTY means a contender owns it; any other failure is recoverable stale state. */ }
413
+ }
414
+ /** The highest published generation. `pid*` files are accepted for recovery compatibility; new
415
+ * owners use atomic `owner.N -> pid:nonce` symlinks. Missing or malformed contents return a
416
+ * generation with no pid, which is stale and can only be superseded by creating the next marker. */
417
+ function lockHolder(lock) {
418
+ try {
419
+ const candidates = readdirSync(lock).flatMap((entry) => {
420
+ const owner = /^owner\.(\d+)$/.exec(entry);
421
+ const gen = lockEntryGeneration(entry);
422
+ return gen === undefined ? [] : [{ gen, entry, owner: owner !== null }];
423
+ }).sort((a, b) => b.gen - a.gen || Number(b.owner) - Number(a.owner));
424
+ const current = candidates[0];
425
+ if (!current)
426
+ return undefined;
427
+ let target;
428
+ try {
429
+ target = current.owner
430
+ ? readlinkSync(join(lock, current.entry))
431
+ : readFileSync(join(lock, current.entry), "utf8").trim();
432
+ }
433
+ catch {
434
+ return { gen: current.gen, entry: current.entry };
435
+ }
436
+ const pidText = current.owner ? /^([1-9]\d*):.+$/.exec(target)?.[1] : target;
437
+ const pid = Number(pidText);
438
+ return Number.isInteger(pid) && pid > 0
439
+ ? { gen: current.gen, entry: current.entry, target, pid }
440
+ : { gen: current.gen, entry: current.entry, target };
441
+ }
442
+ catch {
443
+ return undefined;
444
+ }
445
+ }
446
+ function lockEntryGeneration(entry) {
447
+ const owner = /^owner\.(\d+)$/.exec(entry);
448
+ const legacy = /^pid(?:\.(\d+))?$/.exec(entry);
449
+ return owner ? Number(owner[1]) : legacy ? Number(legacy[1] ?? 0) : undefined;
450
+ }
451
+ function pidLive(pid) {
452
+ try {
453
+ process.kill(pid, 0);
454
+ return true;
455
+ }
456
+ catch (error) {
457
+ return error.code === "EPERM";
458
+ }
459
+ }
460
+ function terminalRows(env) {
461
+ const rows = env.result.terminals;
462
+ if (!Array.isArray(rows))
463
+ throw new OrcaError("list", "list response carries no terminals array", env.raw);
464
+ return rows.filter((r) => typeof r === "object" && r !== null);
465
+ }
466
+ function listsHandle(env, handle) {
467
+ return terminalRows(env).some((r) => str(r.handle) === handle);
468
+ }
469
+ /** A handle listed by a different runtime is not the recorded pane — Orca handles are runtime-scoped. */
470
+ function listedOnRuntime(env, handle, runtimeId) {
471
+ return typeof runtimeId === "string" && env.runtimeId === runtimeId && listsHandle(env, handle);
472
+ }
473
+ function terminalTabId(env, handle) {
474
+ for (const row of terminalRows(env)) {
475
+ if (str(row.handle) === handle)
476
+ return str(row.tabId);
477
+ }
478
+ return undefined;
479
+ }
480
+ /**
481
+ * Orca 1.4.200 split contract: child at `result.split.handle`, parent tab at `result.split.tabId`.
482
+ * A receipt that only happens to contain a handle is malformed — it does not establish that the
483
+ * child belongs to the launching terminal's tab.
484
+ */
485
+ function splitReceipt(env) {
486
+ const receipt = env.result.split;
487
+ if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
488
+ throw new OrcaError("split", "split receipt is unknown, malformed or handle-less", env.raw);
489
+ }
490
+ const rec = receipt;
491
+ const handle = str(rec.handle);
492
+ const tabId = str(rec.tabId);
493
+ if (!handle || !tabId) {
494
+ throw new OrcaError("split", "split receipt is unknown, malformed or handle-less", env.raw);
495
+ }
496
+ return { handle, tabId };
497
+ }
498
+ function watchSlot(cwd, name, handle) {
499
+ return { id: handle, name, cwd: canonicalWorktreePath(cwd) };
500
+ }
501
+ function errorText(error) {
502
+ return error instanceof Error ? error.message : String(error);
503
+ }
207
504
  /** Terminal-pane handles under a layout tab node: a pane object, an array of them, or a nested
208
505
  * group/split carrying `panes`, `first`, `second`. Only `type:"terminal"` leaves count — anything else is chrome. */
209
- function collectPaneHandles(node, out) {
506
+ function collectLeaves(node, out) {
210
507
  if (Array.isArray(node)) {
211
508
  for (const n of node)
212
- collectPaneHandles(n, out);
509
+ collectLeaves(n, out);
213
510
  return;
214
511
  }
215
512
  if (typeof node !== "object" || node === null)
@@ -218,12 +515,18 @@ function collectPaneHandles(node, out) {
218
515
  if (o.type === "terminal") {
219
516
  const h = str(o.handle);
220
517
  if (h)
221
- out.push(h);
518
+ out.push({ handle: h, title: str(o.title) });
222
519
  return;
223
520
  }
224
- collectPaneHandles(o.panes, out);
225
- collectPaneHandles(o.first, out);
226
- collectPaneHandles(o.second, out);
521
+ collectLeaves(o.panes, out);
522
+ collectLeaves(o.first, out);
523
+ collectLeaves(o.second, out);
524
+ }
525
+ function collectPaneHandles(node, out) {
526
+ const leaves = [];
527
+ collectLeaves(node, leaves);
528
+ for (const l of leaves)
529
+ out.push(l.handle);
227
530
  }
228
531
  function collectTabs(node, out) {
229
532
  if (Array.isArray(node)) {
@@ -251,6 +554,13 @@ function collectTabs(node, out) {
251
554
  export function joinWrapped(raw) {
252
555
  return raw.split("\n").map((l) => l.replace(/^[\s│|]+/, "").replace(/[\s│|]+$/, "")).join("");
253
556
  }
557
+ /** The OBS-1011 add.1 capture: `status:"exited", tail:[], returnedLineCount 0` from a stream read. */
558
+ export function isBlindStreamPage(term) {
559
+ return str(term.status) === "exited"
560
+ && term.source !== "screen"
561
+ && Array.isArray(term.tail) && term.tail.length === 0
562
+ && (term.returnedLineCount === undefined || term.returnedLineCount === 0);
563
+ }
254
564
  export class OrcaDriver {
255
565
  id = "orca";
256
566
  interactive = true; // a visible terminal the operator can watch and answer
@@ -266,8 +576,19 @@ export class OrcaDriver {
266
576
  narrate;
267
577
  hookCoverage;
268
578
  taskWorktrees = new Map();
579
+ trackedByCheckout = new Map(); // OBS-1004: checkout → the tracked worktree enclosing it
269
580
  pendingProjects = new Map();
581
+ env;
582
+ launchingHandle;
583
+ serialQueue = Promise.resolve();
584
+ serial(fn) {
585
+ const next = this.serialQueue.then(fn, fn);
586
+ this.serialQueue = next.then(() => { }, () => { });
587
+ return next;
588
+ }
270
589
  constructor(opts = {}) {
590
+ this.env = opts.env ?? process.env;
591
+ this.launchingHandle = opts.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE;
271
592
  this.bin = opts.bin ?? resolveOrcaCliBinary(process.cwd(), { env: opts.env, platform: opts.platform }) ?? "orca";
272
593
  // Config values flow into a shell here: every argv element is quoted, always.
273
594
  this.exec = opts.exec ?? ((args, cwd, timeoutMs) => {
@@ -414,8 +735,8 @@ export class OrcaDriver {
414
735
  // terminalOp proves the runtime binding before the handle goes on the wire.
415
736
  await this.sendText(st, cmd);
416
737
  }
417
- async sendText(st, text) {
418
- const env = await this.terminalOp("send", st, (h) => this.call("send", ["terminal", "send", "--terminal", h, "--text", text, "--enter"], this.cliCwd(st)), { mutating: true });
738
+ async sendText(st, text, waitSubmit = false) {
739
+ const env = await this.terminalOp("send", st, (h) => this.call("send", ["terminal", "send", "--terminal", h, "--text", text, "--enter", ...(waitSubmit ? ["--wait-submit", "15"] : [])], this.cliCwd(st)), { mutating: true });
419
740
  // Recorded 1.4.195 send receipt: result.send = {handle, accepted, bytesWritten}. `ok:true`
420
741
  // alone is not delivery: the accepted receipt must name this handle and account for the bytes.
421
742
  const receipt = this.sendReceipt(env, st);
@@ -423,6 +744,7 @@ export class OrcaDriver {
423
744
  if (receipt.accepted !== true || typeof receipt.bytesWritten !== "number" || receipt.bytesWritten !== expectedBytes) {
424
745
  throw new OrcaError("send", `send receipt does not report expected byte delivery (accepted: ${JSON.stringify(receipt.accepted)}, bytesWritten: ${receipt.bytesWritten}, expected: ${expectedBytes})`, env.raw);
425
746
  }
747
+ return receipt;
426
748
  }
427
749
  sendReceipt(env, st) {
428
750
  const receipt = env.result.send;
@@ -437,20 +759,23 @@ export class OrcaDriver {
437
759
  }
438
760
  async create(st, cmd) {
439
761
  await this.probeRuntime(this.cliCwd(st));
440
- await this.awaitWorktreeAdoption(st);
441
- // The selector names THIS slot's checkout outright, and the CLI child is bound to it too, so
442
- // neither the UI's active worktree (`active`/`current`) nor the daemon's cwd can place it.
762
+ const tracked = await this.trackedWorktree(st.cwd);
763
+ st.tracked = tracked;
764
+ // The selector names the TRACKED worktree enclosing this slot's checkout outright — never the
765
+ // UI's active worktree (`active`/`current`) nor the daemon's cwd — and the command itself moves
766
+ // into the checkout, because Orca cannot be asked to place a terminal in a path it does not
767
+ // track (OBS-1004). `--command` runs inside Orca's wrapper shell, so a `cd` prefix is honoured.
443
768
  const env = await this.call("create", [
444
- "terminal", "create", "--worktree", `path:${st.cwd}`, "--title", st.title, "--command", cmd,
769
+ "terminal", "create", "--worktree", `path:${tracked}`, "--title", st.title, "--command", inCheckout(st.cwd, cmd),
445
770
  ], this.cliCwd(st));
446
771
  const term = requireTerminal("create", env);
447
772
  const handle = str(term.handle);
448
773
  if (!handle)
449
774
  throw new OrcaError("create", "create receipt carries no terminal handle", env.raw);
450
- // Asking is not getting: the receipt says which checkout the runtime actually resolved, and a
451
- // terminal in the wrong one has already lost the isolation this run is built on.
775
+ // Asking is not getting: the receipt says which worktree the runtime actually resolved, and a
776
+ // terminal bound elsewhere has already lost the isolation this run is built on.
452
777
  const worktree = terminalWorktree(term);
453
- if (!sameWorktree(worktree, st.cwd)) {
778
+ if (!sameWorktree(worktree, tracked)) {
454
779
  // `terminal create` has ALREADY launched the command in that wrong checkout, so refusing the
455
780
  // receipt is not yet fail-closed: the agent keeps mutating it. Close the exact handle this
456
781
  // receipt named, under the runtime that answered it (closeTerminal re-proves that identity
@@ -461,7 +786,7 @@ export class OrcaDriver {
461
786
  await this.closeTerminal({ ...st, handle, runtimeId: env.runtimeId });
462
787
  }
463
788
  catch { /* already gone, or no longer provably ours — never a blind retry */ }
464
- throw this.latched("create", st, `create receipt bound to ${worktree ?? "no worktree"}, not the slot's ${st.cwd}`, env.raw);
789
+ throw this.latched("create", st, `create receipt bound to ${worktree ?? "no worktree"}, not the tracked ${tracked} enclosing ${st.cwd}`, env.raw);
465
790
  }
466
791
  st.handle = handle;
467
792
  // The handle is bound to the runtime identity that ANSWERED its create.
@@ -473,62 +798,30 @@ export class OrcaDriver {
473
798
  }
474
799
  }
475
800
  /**
476
- * A freshly-created git checkout does not become a valid Orca selector atomically. Ask
477
- * `worktree current` FROM that checkout until Orca itself resolves the exact filesystem identity;
478
- * an enclosing checkout is still not adoption. Only selector_not_found is a retryable refusal —
479
- * malformed envelopes and every other refusal remain explicit driver failures.
801
+ * OBS-1004: the tracked worktree that encloses a checkout, asked ONCE of `worktree current` from
802
+ * inside that checkout. Orca answers the exact path when it tracks the checkout itself, the
803
+ * enclosing tracked clone for a git worktree the daemon added beneath it (1.4.200, verified from
804
+ * `.tickmarkr/worktrees.noindex/<task>`), and selector_not_found when nothing it tracks encloses
805
+ * the cwd — which is a driver failure, not something to wait out: Orca has no adopt verb.
480
806
  */
481
- async awaitWorktreeAdoption(st) {
482
- const started = this.time.now();
483
- const deadline = started + WORKTREE_ADOPTION_TIMEOUT_MS;
484
- let lastReported;
485
- for (;;) {
486
- const left = deadline - this.time.now();
487
- if (left < 0)
488
- break;
489
- try {
490
- const env = await this.call("worktree-current", ["worktree", "current"], st.cwd, Math.max(1, left));
491
- const worktree = env.result.worktree;
492
- if (typeof worktree !== "object" || worktree === null || Array.isArray(worktree)) {
493
- throw new OrcaError("worktree-current", "response carries no worktree record", env.raw, { runtimeId: env.runtimeId });
494
- }
495
- const reported = terminalWorktree(worktree);
496
- if (!reported) {
497
- throw new OrcaError("worktree-current", "worktree record carries no path", env.raw, { runtimeId: env.runtimeId });
498
- }
499
- lastReported = canonicalWorktreePath(reported);
500
- if (lastReported === st.cwd) {
501
- const waitedMs = this.time.now() - started;
502
- if (waitedMs > WORKTREE_ADOPTION_JOURNAL_MS)
503
- this.appendAdoptionWait(st, waitedMs);
504
- return;
505
- }
506
- }
507
- catch (error) {
508
- if (!(error instanceof OrcaError) || error.code !== "selector_not_found")
509
- throw error;
510
- lastReported = undefined;
511
- }
512
- const remaining = deadline - this.time.now();
513
- if (remaining <= 0)
514
- break;
515
- await this.time.sleep(Math.min(WORKTREE_ADOPTION_POLL_MS, remaining));
516
- }
517
- const waitedMs = this.time.now() - started;
518
- throw new OrcaUnavailableError("worktree-current", `Orca did not adopt ${st.cwd} within ${WORKTREE_ADOPTION_TIMEOUT_MS}ms${lastReported ? ` (last answered ${lastReported})` : ""}`, `waitedMs=${waitedMs}`);
519
- }
520
- /** Same repo/run/narration path Herdr uses for its driver-owned dispatch-retry row. */
521
- appendAdoptionWait(st, waitedMs) {
522
- const owned = parseOwnedName(st.title);
523
- if (!owned)
524
- throw new Error(`cannot journal worktree-adoption-wait: slot ${st.title} carries no run identity`);
525
- const repoRoot = this.journalRoots.get(st.cwd);
526
- if (!repoRoot) {
527
- throw new Error(`cannot journal worktree-adoption-wait: slot ${st.title} has no daemon repo binding for ${st.cwd}`);
528
- }
529
- Journal.open(repoRoot, owned.runId, this.narrate).append("worktree-adoption-wait", owned.taskId, {
530
- milliseconds: waitedMs,
531
- });
807
+ async trackedWorktree(checkout) {
808
+ const cached = this.trackedByCheckout.get(checkout);
809
+ if (cached)
810
+ return cached;
811
+ const env = await this.call("worktree-current", ["worktree", "current"], checkout);
812
+ const worktree = env.result.worktree;
813
+ if (typeof worktree !== "object" || worktree === null || Array.isArray(worktree)) {
814
+ throw new OrcaError("worktree-current", "response carries no worktree record", env.raw, { runtimeId: env.runtimeId });
815
+ }
816
+ const reported = terminalWorktree(worktree);
817
+ if (!reported)
818
+ throw new OrcaError("worktree-current", "worktree record carries no path", env.raw, { runtimeId: env.runtimeId });
819
+ const tracked = canonicalWorktreePath(reported);
820
+ if (tracked !== checkout && !checkout.startsWith(`${tracked}/`)) {
821
+ throw new OrcaError("worktree-current", `Orca answered ${tracked}, which does not enclose ${checkout}`, env.raw, { runtimeId: env.runtimeId });
822
+ }
823
+ this.trackedByCheckout.set(checkout, tracked);
824
+ return tracked;
532
825
  }
533
826
  // ---- handle identity and restart recovery ----------------------------------------------------
534
827
  /**
@@ -605,6 +898,65 @@ export class OrcaDriver {
605
898
  return env;
606
899
  }
607
900
  }
901
+ /**
902
+ * FX-N01/N05/N06: under a shared enclosing worktree every task terminal lists with the same
903
+ * worktreePath, so the tracked path + owned title cannot tell two nested checkouts apart. The
904
+ * runtime's own proof is the terminal's earliest scrollback, where the create command printed a
905
+ * framed `TICKMARKR_CHECKOUT` line before its payload (checkoutProofLine). READ-only calls: the
906
+ * anchor (for `oldestCursor`), then pages from the oldest cursor until a frame is complete or the
907
+ * bound is hit. Every page is evidence only when the response's own identity is the candidate's:
908
+ * the terminal record must name `handle` and `_meta.runtimeId` must be `runtimeId` — the runtime
909
+ * that supplied the ownership listing — else another terminal's or another runtime's bytes were
910
+ * answered and nothing is proven. Proven means provesCheckout: exact canonical full-path equality
911
+ * of a complete frame, no other checkout named, no incomplete frame.
912
+ */
913
+ async checkoutProven(handle, checkout, from, runtimeId) {
914
+ const page = async (cursor) => {
915
+ const env = await this.call("read", [
916
+ "terminal", "read", "--terminal", handle, ...(cursor === undefined ? [] : ["--cursor", cursor]), "--limit", String(this.pageLines),
917
+ ], from);
918
+ if (env.runtimeId !== runtimeId) {
919
+ throw new OrcaError("read", `proof page answered by runtime ${env.runtimeId}, not the listing's ${runtimeId}`, env.raw);
920
+ }
921
+ const term = requireTerminal("read", env);
922
+ if (str(term.handle) !== handle) {
923
+ throw new OrcaError("read", `proof page names terminal ${str(term.handle) ?? "none"}, not the candidate ${handle}`, env.raw);
924
+ }
925
+ return term;
926
+ };
927
+ const lines = (term) => Array.isArray(term.tail) ? term.tail.filter((l) => typeof l === "string") : [];
928
+ try {
929
+ const anchor = await page();
930
+ let cursor = str(anchor.oldestCursor);
931
+ let text = cursor === undefined ? lines(anchor).join("\n") : "";
932
+ for (let n = 0; cursor !== undefined && n < PROOF_PAGES; n++) {
933
+ const term = await page(cursor);
934
+ text += `${lines(term).join("\n")}\n`;
935
+ const frames = checkoutFrames(text);
936
+ if (frames.complete.length > 0 && !frames.incomplete)
937
+ break; // whole frames, nothing dangling
938
+ const next = str(term.nextCursor);
939
+ if (term.limited !== true || next === undefined || next === cursor)
940
+ break;
941
+ cursor = next;
942
+ }
943
+ const frames = checkoutFrames(text);
944
+ // Reconcile has no task-checkout path after a daemon restart, but the proof itself remains
945
+ // an ownership record: exactly one complete, unambiguous checkout frame can only have been
946
+ // written by tickmarkr's create command. Slot recovery additionally requires its exact path.
947
+ if (checkout === undefined
948
+ ? !frames.incomplete && frames.complete.length > 0 && new Set(frames.complete).size === 1
949
+ : provesCheckout(text, checkout))
950
+ return { proven: true, reason: "" };
951
+ return {
952
+ proven: false,
953
+ reason: `its scrollback ${frames.complete.length ? `names ${[...new Set(frames.complete)].join(", ")}` : "names no checkout"}${frames.incomplete ? " and carries an incomplete proof frame" : ""}`,
954
+ };
955
+ }
956
+ catch (error) {
957
+ return { proven: false, reason: error instanceof Error ? error.message : String(error) };
958
+ }
959
+ }
608
960
  async recover(family, st, newRuntimeId, raw) {
609
961
  if (st.recovering)
610
962
  throw this.latched(family, st, "handle recovery re-entered", raw);
@@ -613,7 +965,8 @@ export class OrcaDriver {
613
965
  const old = st.handle;
614
966
  // visualLayouts is required: the owned title survives at TAB identity only, and rows carry
615
967
  // just the shell-controlled pane title (recorded: "…probe…" at create → "bash" on the row).
616
- const env = await this.call("list", ["terminal", "list", "--worktree", `path:${st.cwd}`, "--include-visual-layouts", "--limit", String(LIST_LIMIT)], this.cliCwd(st));
968
+ const home = st.tracked ?? st.cwd; // the worktree Orca placed this terminal in (OBS-1004)
969
+ const env = await this.call("list", ["terminal", "list", "--worktree", `path:${home}`, "--include-visual-layouts", "--limit", String(LIST_LIMIT)], this.cliCwd(st));
617
970
  const listed = env.result.terminals;
618
971
  if (!Array.isArray(listed))
619
972
  throw new OrcaError("list", "list response carries no terminals array", env.raw);
@@ -625,7 +978,7 @@ export class OrcaDriver {
625
978
  // The worktree-authoritative rows of THIS worktree: a handle is adoptable only if a row in
626
979
  // the slot's exact worktree backs it. A same-titled tab in another worktree is a lookalike.
627
980
  const inWorktree = new Set(listed
628
- .filter((t) => typeof t === "object" && t !== null && sameWorktree(terminalWorktree(t), st.cwd))
981
+ .filter((t) => typeof t === "object" && t !== null && sameWorktree(terminalWorktree(t), home))
629
982
  .map((t) => str(t.handle))
630
983
  .filter((h) => h !== undefined));
631
984
  // Exactly one tab carrying the FULL owned title, resolving to exactly one terminal pane.
@@ -636,7 +989,7 @@ export class OrcaDriver {
636
989
  if (typeof layout !== "object" || layout === null)
637
990
  continue;
638
991
  const lo = layout;
639
- if (!sameWorktree(terminalWorktree(lo), st.cwd))
992
+ if (!sameWorktree(terminalWorktree(lo), home))
640
993
  continue; // another worktree's tabs are never candidates
641
994
  const tabs = [];
642
995
  collectTabs(lo.root, tabs);
@@ -649,14 +1002,14 @@ export class OrcaDriver {
649
1002
  }
650
1003
  }
651
1004
  if (ownedTabs.length === 0) {
652
- 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);
1005
+ throw this.latched(family, st, `no tab in ${home} carries the owned title ${st.title} (row titles are shell-controlled and are never ownership keys)`, env.raw);
653
1006
  }
654
1007
  if (ownedTabs.length > 1) {
655
- throw this.latched(family, st, `${ownedTabs.length} tabs in ${st.cwd} carry the owned title ${st.title} — ambiguous`, env.raw);
1008
+ throw this.latched(family, st, `${ownedTabs.length} tabs in ${home} carry the owned title ${st.title} — ambiguous`, env.raw);
656
1009
  }
657
1010
  const panes = ownedTabs[0].filter((h) => inWorktree.has(h));
658
1011
  if (panes.length !== 1) {
659
- throw this.latched(family, st, `the owned tab resolves to ${panes.length} terminals in ${st.cwd}`, env.raw);
1012
+ throw this.latched(family, st, `the owned tab resolves to ${panes.length} terminals in ${home}`, env.raw);
660
1013
  }
661
1014
  const handle = panes[0];
662
1015
  if (handle === old) {
@@ -664,6 +1017,14 @@ export class OrcaDriver {
664
1017
  // which terminal it addresses, so it is never adopted.
665
1018
  throw this.latched(family, st, `replacement handle ${handle} is the old handle value reused by runtime ${newRuntimeId ?? env.runtimeId ?? "unknown"}`, env.raw);
666
1019
  }
1020
+ if (st.tracked !== undefined && st.tracked !== st.cwd) {
1021
+ // FX-N01: a nested checkout shares its worktreePath with every sibling task's terminal, so
1022
+ // the candidate must prove the checkout itself (read-only) before it is addressed as ours.
1023
+ const proof = await this.checkoutProven(handle, st.cwd, this.cliCwd(st), env.runtimeId);
1024
+ if (!proof.proven) {
1025
+ throw this.latched(family, st, `candidate ${handle} does not prove checkout ${st.cwd} (${proof.reason}); the sole same-titled tab on ${home} is not adopted`, env.raw);
1026
+ }
1027
+ }
667
1028
  st.handle = handle;
668
1029
  // The list response is the identity proof for the replacement. A stale refusal may have been
669
1030
  // produced by either side of a restart; its metadata never overrides the runtime that relisted.
@@ -727,8 +1088,35 @@ export class OrcaDriver {
727
1088
  ...(cursor === undefined || recovered ? [] : ["--cursor", cursor]),
728
1089
  "--limit", String(lines),
729
1090
  ], this.cliCwd(st)), { onRecovered: () => { recovered = true; } });
1091
+ if (isBlindStreamPage(requireTerminal(family, env))) {
1092
+ return { term: await this.screenBehindBlindStream(family, st, env), raw: env.raw, recovered };
1093
+ }
730
1094
  return { term: this.validated(family, st, env), raw: env.raw, recovered };
731
1095
  }
1096
+ /**
1097
+ * OBS-1011 add.1 / OBS-1016: the captured incident shape — a stream page answering status exited
1098
+ * with an empty tail on a terminal that accepted a send seconds earlier — is BLIND, not dead, when
1099
+ * the same handle's show record on the same runtime reports connected and not orphaned (show carries
1100
+ * no status field; none is demanded) and its screen read reports running. Then the rendered frame is
1101
+ * the terminal's bytes. Anything less — disconnected, orphaned, another handle or runtime, a screen
1102
+ * that is unavailable or exited — is refused as unavailable, exactly as the dead record would be.
1103
+ */
1104
+ async screenBehindBlindStream(family, st, blind) {
1105
+ const handle = str(requireTerminal(family, blind).handle);
1106
+ if (handle !== st.handle) {
1107
+ throw new OrcaUnavailableError(family, `terminal record names ${handle ?? "no handle"}, not the addressed ${st.handle}`, blind.raw);
1108
+ }
1109
+ const show = await this.call("show", ["terminal", "show", "--terminal", st.handle], this.cliCwd(st));
1110
+ if (show.runtimeId !== st.runtimeId) {
1111
+ throw new OrcaUnavailableError(family, `exited-shaped stream page: show answered by runtime ${show.runtimeId}, not the bound ${st.runtimeId}`, show.raw, "exited");
1112
+ }
1113
+ this.liveShowTerm(family, st, show);
1114
+ const screen = await this.readScreen(st);
1115
+ if (screen.source !== "screen") {
1116
+ throw new OrcaUnavailableError(family, `exited-shaped stream page and no rendered screen for ${st.handle}`, blind.raw, "exited");
1117
+ }
1118
+ return screen.term;
1119
+ }
732
1120
  /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
733
1121
  async readScreen(st) {
734
1122
  const env = await this.terminalOp("status", st, (h) => this.call("read", [
@@ -976,17 +1364,26 @@ export class OrcaDriver {
976
1364
  const st = this.state(slot);
977
1365
  if (!await this.waitCondition(st, "tui-idle", 1))
978
1366
  return false;
979
- // Drain to the current stream cursor before sending. A message already present makes the
980
- // proof ambiguous, so decline rather than reporting delivery from old scrollback.
981
- const before = await this.sweep(st);
982
- if (before.includes(message) || joinWrapped(before).includes(message))
983
- return false;
984
- await this.sendText(st, message);
1367
+ const receipt = await this.sendText(st, message, true);
1368
+ // OBS-1016: delivery is proven by the receipt — a prompt stage of turn_started — or by the
1369
+ // composer emptying on a screen read, never by stream echo: the stream is blind on a live Orca
1370
+ // terminal (OBS-1011 add.1), so an echo sweep fails every nudge and latches the harvest hold.
1371
+ const prompt = typeof receipt.prompt === "object" && receipt.prompt !== null && !Array.isArray(receipt.prompt)
1372
+ ? receipt.prompt
1373
+ : {};
1374
+ const stages = Array.isArray(prompt.stages) ? prompt.stages : [];
1375
+ if (stages.includes("turn_started"))
1376
+ return true;
985
1377
  const deadline = this.time.now() + NUDGE_ECHO_TIMEOUT_MS;
986
1378
  for (;;) {
987
- const after = await this.sweep(st);
988
- if (after.includes(message) || joinWrapped(after).includes(message))
989
- return true;
1379
+ const screen = await this.readScreen(st);
1380
+ // ponytail: "composer emptied" is "the text is no longer on the frame" — a TUI that keeps the
1381
+ // submitted turn on screen reads as undelivered until the timeout; the receipt stage is primary.
1382
+ if (screen.source === "screen") {
1383
+ const frame = this.tailText("status", screen.term, "");
1384
+ if (!frame.includes(message) && !joinWrapped(frame).includes(message))
1385
+ return true;
1386
+ }
990
1387
  const left = deadline - this.time.now();
991
1388
  if (left <= 0)
992
1389
  return false;
@@ -997,12 +1394,228 @@ export class OrcaDriver {
997
1394
  return false;
998
1395
  }
999
1396
  }
1000
- async narrator(_cwd, _command, runId) {
1397
+ async narrator(cwd, command, runId) {
1001
1398
  if (!runId)
1002
- throw new OrcaError("create", "Orca narrator requires a run identity", "");
1003
- // The recorded Orca API can create a tab but provides no right/no-focus
1004
- // placement receipt. Creating one would advertise a board we did not place.
1005
- throw new OrcaError("create", "Orca narrator placement unsupported: right/no-focus board placement is not available", "");
1399
+ throw new OrcaError("split", "Orca narrator requires a run identity", "");
1400
+ const launchingHandle = (this.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE)?.trim();
1401
+ if (!launchingHandle)
1402
+ throw new OrcaError("split", "Orca narrator requires ORCA_TERMINAL_HANDLE", "");
1403
+ return this.serial(async () => {
1404
+ const name = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
1405
+ const path = this.boardPath(cwd, runId);
1406
+ const kept = `indeterminate cleanup refused — owner record ${path} kept`;
1407
+ let current = readBoard(path, cwd, runId);
1408
+ if (current && (current.record.driver !== this.id || current.record.name !== name)) {
1409
+ // Never overwrite a record this driver did not create, unless its observer is provably gone.
1410
+ const { driver, pid } = current.record;
1411
+ if (typeof pid !== "number" || pidLive(pid)) {
1412
+ throw new OrcaError("split", `Orca narrator placement refused for ${name}: the record is held by driver ${driver} with a live or unclaimed observer; ${kept}`, "");
1413
+ }
1414
+ }
1415
+ else if (current) {
1416
+ const state = boardState(current.record);
1417
+ if (state === "reserved" || state === "claimed") {
1418
+ // A reservation or claim with no bound pane is the placing narrator's normal intermediate
1419
+ // state while that narrator lives (awaiting its receipt, listing, claim or bind): refused
1420
+ // untouched, its observer never asked to stop. Only a DEAD placer (or one unrecorded) makes
1421
+ // it a crash to recover.
1422
+ const { placer } = current.record;
1423
+ if (typeof placer !== "number" || pidLive(placer)) {
1424
+ throw new OrcaError("split", `Orca narrator placement remains unresolved for ${name} (${state}, no bound pane) while placing narrator ${placer ?? "unrecorded"} lives; ${kept}`, "");
1425
+ }
1426
+ }
1427
+ if (state === "claimed") {
1428
+ // The placing driver crashed between the observer's claim and its bind. The record is
1429
+ // replaced only once that observer is proven gone: dead outright, or live and stopped with
1430
+ // its acknowledgement awaited on injected time — unacknowledged, it is refused untouched.
1431
+ const pid = current.record.pid;
1432
+ if (pidLive(pid)) {
1433
+ try {
1434
+ await stopWatchBoard(current.record, this.time);
1435
+ }
1436
+ catch (error) {
1437
+ throw new OrcaError("split", `Orca narrator placement refused for ${name}: claimed board's live observer unacknowledged; ${kept}; ${errorText(error)}`, "");
1438
+ }
1439
+ }
1440
+ const retired = { ...current.record, retired: true };
1441
+ current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
1442
+ }
1443
+ if (state === "reserved") {
1444
+ // The placer died before any claim: no observer exists, its split pane (if any) is unknown
1445
+ // to the record. Tombstoned in place so the generic tail below replaces it.
1446
+ const retired = { ...current.record, retired: true };
1447
+ current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
1448
+ }
1449
+ if (state === "bound") {
1450
+ // The bound record is the durable truth of placement: the child handle PLUS the split
1451
+ // envelope's runtime identity. A handle listed by a later runtime is a different pane.
1452
+ if (listedOnRuntime(await this.listAll(cwd), current.record.pane, current.record.runtimeId)) {
1453
+ return watchSlot(cwd, name, current.record.pane);
1454
+ }
1455
+ // Lost: this runtime no longer has that pane. Tombstone before anything else.
1456
+ const retired = { ...current.record, retired: true };
1457
+ current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
1458
+ }
1459
+ // A tombstone can be an acknowledgement timeout, not completed observer cleanup. A live
1460
+ // observer must stop and ack on injected time before the handle-bound close; timeout keeps
1461
+ // the tombstone and does not replace it.
1462
+ try {
1463
+ if (typeof current.record.pid === "number" && pidLive(current.record.pid)) {
1464
+ await stopWatchBoard(current.record, this.time);
1465
+ }
1466
+ else {
1467
+ requestWatchBoardStop(current.record);
1468
+ }
1469
+ }
1470
+ catch (error) {
1471
+ throw new OrcaError("split", `Orca narrator placement refused for ${name}: retired board observer unacknowledged; ${kept}; ${errorText(error)}`, "");
1472
+ }
1473
+ try {
1474
+ await this.closeRecordedPane(cwd, name, current.record.pane, current.record.runtimeId);
1475
+ }
1476
+ catch (error) {
1477
+ throw new OrcaError("split", `Orca narrator placement refused for ${name}: retired board pane ${current.record.pane} not proven closed; ${kept}; ${errorText(error)}`, "");
1478
+ }
1479
+ }
1480
+ // Reserve before any command can read the token: create-only, or a swap of the exact record
1481
+ // judged replaceable above.
1482
+ const token = randomUUID();
1483
+ await casBoard("split", path, current?.raw, { repo: realpathSync(cwd), runId, driver: this.id, workspace: ORCA_SPACE, pane: "", name, token, placer: process.pid }, this.time);
1484
+ // A reservation that can never be bound — no usable receipt, or no claim — is tombstoned so the
1485
+ // next narrator call splits afresh instead of refusing forever. Only THIS reservation, and only
1486
+ // while it is still the untouched reservation; a record anyone else moved is left to them.
1487
+ const tombstone = async (extra = {}) => {
1488
+ try {
1489
+ const now = readBoard(path, cwd, runId);
1490
+ if (now?.record.token === token && boardState(now.record) === "reserved") {
1491
+ await casBoard("split", path, now.raw, { ...now.record, ...extra, retired: true }, this.time);
1492
+ }
1493
+ }
1494
+ catch { /* the record is kept as it stands; the next narrator judges it */ }
1495
+ };
1496
+ // Exactly one terminal split of the launching handle, horizontal, carrying token and command.
1497
+ // An unknown receipt (transport failure, refusal, unparseable output) after the verb was issued
1498
+ // is as indeterminate as a handle-less one: the pane may exist, so the reservation stays.
1499
+ const env = await this.call("split", [
1500
+ "terminal", "split",
1501
+ "--terminal", launchingHandle,
1502
+ "--direction", "horizontal",
1503
+ "--command", `${WATCH_OWNER_ENV}=${shq(token)} ${command}`,
1504
+ ], cwd).catch(async (error) => {
1505
+ await tombstone();
1506
+ throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown (${error instanceof OrcaError ? error.reason : errorText(error)}); ${kept}`, error instanceof OrcaError ? error.raw : "");
1507
+ });
1508
+ let child;
1509
+ try {
1510
+ child = splitReceipt(env);
1511
+ }
1512
+ catch {
1513
+ await tombstone();
1514
+ throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
1515
+ }
1516
+ // Parent tabId must name the launching terminal's tab, and the child must actually appear
1517
+ // there. A handle-only object, a tabId for some other tab, or a handle the list does not
1518
+ // place in that tab is malformed — closing it would be a guessed handle.
1519
+ let listing;
1520
+ try {
1521
+ listing = await this.listAll(cwd);
1522
+ }
1523
+ catch (error) {
1524
+ await tombstone();
1525
+ throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, error instanceof OrcaError ? error.raw : env.raw);
1526
+ }
1527
+ const launchingTabId = terminalTabId(listing, launchingHandle);
1528
+ if (!launchingTabId
1529
+ || child.tabId !== launchingTabId
1530
+ || child.handle === launchingHandle
1531
+ || terminalTabId(listing, child.handle) !== launchingTabId) {
1532
+ await tombstone();
1533
+ throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
1534
+ }
1535
+ const childHandle = child.handle;
1536
+ // The narrator writes nothing until the observer's single claim is visible, so neither write
1537
+ // can erase the other; after it the observer never writes the record again.
1538
+ const claimDeadline = this.time.now() + 5000;
1539
+ let claim;
1540
+ for (;;) {
1541
+ const check = readBoard(path, cwd, runId);
1542
+ if (check?.record.token !== token)
1543
+ break;
1544
+ if (boardState(check.record) === "claimed") {
1545
+ claim = check;
1546
+ break;
1547
+ }
1548
+ if (this.time.now() > claimDeadline)
1549
+ break;
1550
+ await this.time.sleep(20);
1551
+ }
1552
+ if (!claim) {
1553
+ // The receipt's handle may be closed; the reservation is tombstoned naming that pane so the
1554
+ // next narrator can prove it gone (or close it) and split afresh.
1555
+ await tombstone({ pane: childHandle, runtimeId: env.runtimeId });
1556
+ let pane;
1557
+ try {
1558
+ await this.closeRecordedPane(cwd, name, childHandle, env.runtimeId);
1559
+ pane = `its pane ${childHandle} was closed`;
1560
+ }
1561
+ catch (error) {
1562
+ pane = `its pane ${childHandle} was not proven closed (${errorText(error)})`;
1563
+ }
1564
+ throw new OrcaError("split", `Orca narrator board unclaimed for ${name}: the observer never claimed the record (unclaimed board); ${kept}; ${pane}`, env.raw);
1565
+ }
1566
+ await casBoard("split", path, claim.raw, { ...claim.record, pane: childHandle, runtimeId: env.runtimeId }, this.time);
1567
+ return watchSlot(cwd, name, childHandle);
1568
+ });
1569
+ }
1570
+ boardPath(cwd, runId) {
1571
+ return join(cwd, stateDirName(cwd), "supervision", `watch-board.${parseRunId(runId)}.json`);
1572
+ }
1573
+ /** A recorded pane (a receipt's handle bound to the split envelope's runtime, never a guess) is
1574
+ * gone when that runtime no longer lists it, or when a handle-bound close receipt names it.
1575
+ * A handle listed by a different runtime is a different pane — not closed, treated as gone. */
1576
+ async closeRecordedPane(cwd, name, handle, runtimeId) {
1577
+ const listing = await this.listAll(cwd);
1578
+ if (!listedOnRuntime(listing, handle, runtimeId))
1579
+ return;
1580
+ await this.closeTerminal({
1581
+ title: name, cwd: canonicalWorktreePath(cwd), handle, runtimeId, buf: "", recoveries: 0, recovering: false,
1582
+ });
1583
+ }
1584
+ /** bound → retired, decided from the record alone: it must be this driver's board for exactly this
1585
+ * slot's pane. Already retired is returned as it is. */
1586
+ async retireBoard(family, slot) {
1587
+ const runId = parseOwnedName(slot.name)?.runId;
1588
+ const path = runId ? this.boardPath(slot.cwd, runId) : undefined;
1589
+ const current = path && runId ? readBoard(path, slot.cwd, runId) : undefined;
1590
+ const r = current?.record;
1591
+ if (!path || !current || !r || r.driver !== this.id || r.name !== slot.name || r.pane !== slot.id || boardState(r) === "reserved" || boardState(r) === "claimed") {
1592
+ throw new OrcaError(family, `watch ownership unknown or foreign for ${slot.name}; existing board protected`, "");
1593
+ }
1594
+ if (r.retired)
1595
+ return r;
1596
+ const retired = { ...r, retired: true };
1597
+ await casBoard(family, path, current.raw, retired, this.time);
1598
+ return retired;
1599
+ }
1600
+ /** bound → retired first: whatever fails below, no later call answers this board again. A live
1601
+ * observer is asked to stop and its acknowledgement awaited on injected time before the
1602
+ * handle-bound close (timeout keeps the tombstone and the pane); a dead one never acknowledges,
1603
+ * so it is only asked. */
1604
+ async retireAndClose(slot) {
1605
+ await this.serial(async () => {
1606
+ const retired = await this.retireBoard("close", slot);
1607
+ if (typeof retired.pid === "number" && pidLive(retired.pid))
1608
+ await stopWatchBoard(retired, this.time);
1609
+ else
1610
+ requestWatchBoardStop(retired);
1611
+ await this.closeRecordedPane(slot.cwd, slot.name, slot.id, retired.runtimeId);
1612
+ });
1613
+ }
1614
+ /** WB-1 seam: the daemon reports this board lost. "Lost" can be a stale beat or missing presence
1615
+ * under a still-live owner pid, so it is not proof of a dead observer — retirement keeps close's
1616
+ * acknowledgement discipline (Leg-2 T9 P1). */
1617
+ async retireLostWatch(slot) {
1618
+ await this.retireAndClose(slot);
1006
1619
  }
1007
1620
  async focus(target) {
1008
1621
  const { slot, runId, taskId, attempt } = target;
@@ -1021,8 +1634,8 @@ export class OrcaDriver {
1021
1634
  if (typeof layout !== "object" || layout === null)
1022
1635
  continue;
1023
1636
  const lo = layout;
1024
- if (!sameWorktree(terminalWorktree(lo), cwd))
1025
- continue;
1637
+ if (!enclosesCheckout(terminalWorktree(lo), cwd))
1638
+ continue; // the tracked worktree Orca placed it in (OBS-1004)
1026
1639
  const tabs = [];
1027
1640
  collectTabs(lo.root, tabs);
1028
1641
  for (const tab of tabs) {
@@ -1032,9 +1645,15 @@ export class OrcaDriver {
1032
1645
  }
1033
1646
  if (handles.length !== 1)
1034
1647
  return { status: rows.length ? "foreign" : "closed", reason: "No unique owned terminal in the recorded worktree" };
1035
- const matches = rows.filter(row => typeof row === "object" && row !== null && str(row.handle) === handles[0] && sameWorktree(terminalWorktree(row), cwd));
1648
+ const matches = rows.filter(row => typeof row === "object" && row !== null && str(row.handle) === handles[0] && enclosesCheckout(terminalWorktree(row), cwd));
1036
1649
  if (matches.length !== 1)
1037
1650
  return { status: "foreign", reason: "Terminal worktree ownership is unverified" };
1651
+ if (!sameWorktree(terminalWorktree(matches[0]), cwd)) {
1652
+ // FX-N01: the row only ENCLOSES the recorded checkout — require the terminal's own proof line.
1653
+ const proof = await this.checkoutProven(handles[0], cwd, cwd, env.runtimeId);
1654
+ if (!proof.proven)
1655
+ return { status: "foreign", reason: `Terminal checkout ownership is unverified: ${proof.reason}` };
1656
+ }
1038
1657
  if (matches[0].connected === false || matches[0].orphaned === true)
1039
1658
  return { status: "closed", reason: "Recorded terminal is no longer running; open task evidence" };
1040
1659
  return { status: "unsupported", reason: "Owned terminal verified; this Orca API has no focus operation. Open task evidence with Enter" };
@@ -1053,12 +1672,15 @@ export class OrcaDriver {
1053
1672
  }
1054
1673
  await this.setWorkspaceStatus(worktree, state);
1055
1674
  }
1056
- async setWorkspaceStatus(worktree, state) {
1675
+ async setWorkspaceStatus(checkout, state) {
1676
+ // OBS-1004: the task checkout is not an Orca selector (`selector_not_found`, recorded on run
1677
+ // 0004's T5); the projection lands on the tracked worktree that encloses it.
1678
+ const tracked = await this.trackedWorktree(checkout);
1057
1679
  // UNRECORDED SHAPE: no Orca 1.4.195 `worktree set` receipt was captured. The shared envelope
1058
1680
  // parser is the complete success proof here; no result payload is assumed or fabricated.
1059
1681
  await this.call("worktree-set", [
1060
- "worktree", "set", "--worktree", `path:${worktree}`, "--workspace-status", state,
1061
- ], worktree);
1682
+ "worktree", "set", "--worktree", `path:${tracked}`, "--workspace-status", state,
1683
+ ], checkout);
1062
1684
  }
1063
1685
  async notify(msg, opts) {
1064
1686
  if (opts?.tier === "routine")
@@ -1069,11 +1691,16 @@ export class OrcaDriver {
1069
1691
  this.narrate = narrate;
1070
1692
  }
1071
1693
  async close(slot) {
1694
+ if (parseOwnedName(slot.name)?.role === "watch") {
1695
+ await this.retireAndClose(slot);
1696
+ return;
1697
+ }
1072
1698
  const st = this.slots.get(slot.id);
1073
1699
  if (!st)
1074
1700
  return;
1075
- if (st.handle)
1701
+ if (st.handle) {
1076
1702
  await this.closeTerminal(st);
1703
+ }
1077
1704
  this.slots.delete(slot.id);
1078
1705
  }
1079
1706
  /**
@@ -1204,10 +1831,24 @@ export class OrcaDriver {
1204
1831
  const title = str(t.title);
1205
1832
  if (!title)
1206
1833
  continue;
1207
- const handles = [];
1208
- collectPaneHandles(t.panes, handles); // a split tab holds more than one, and both are ours
1209
- for (const handle of handles)
1210
- candidates.set(handle, { title, worktree: canonicalWorktreePath(worktree) });
1834
+ const leaves = [];
1835
+ collectLeaves(t.panes, leaves);
1836
+ const launching = (this.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE)?.trim();
1837
+ // A tab title belongs to the tab, not to a leaf. After the operator moves the worker
1838
+ // out, a foreign shell can sit alone under that owned title — so every leaf, including
1839
+ // the only leaf of a single-leaf tab, needs the durable checkout proof. A title that
1840
+ // does not parse is never a candidate.
1841
+ if (!parseOwnedName(title))
1842
+ continue;
1843
+ for (const leaf of leaves) {
1844
+ if (launching && leaf.handle === launching)
1845
+ continue;
1846
+ if (this.isRecordedWatchHandle(leaf.handle, worktree, runId, env.runtimeId))
1847
+ continue;
1848
+ if (!await this.isRecordedWorkerHandle(leaf.handle, from, env.runtimeId))
1849
+ continue;
1850
+ candidates.set(leaf.handle, { title, worktree: canonicalWorktreePath(worktree) });
1851
+ }
1211
1852
  }
1212
1853
  }
1213
1854
  const toClose = panesToClose([...candidates].map(([paneId, c]) => ({ name: c.title, paneId, workspaceId: ORCA_SPACE })), desired, ORCA_SPACE, runId, opts);
@@ -1232,6 +1873,36 @@ export class OrcaDriver {
1232
1873
  }
1233
1874
  catch { /* cosmetic — visibility hygiene never fails the run */ }
1234
1875
  }
1876
+ isRecordedWatchHandle(handle, cwd, runId, runtimeId) {
1877
+ const matches = (record) => !!record && record.pane === handle && record.runtimeId === runtimeId;
1878
+ if (runId && matches(readWatchBoard(cwd, runId)))
1879
+ return true;
1880
+ try {
1881
+ const dir = join(cwd, stateDirName(cwd), "supervision");
1882
+ if (existsSync(dir)) {
1883
+ for (const f of readdirSync(dir)) {
1884
+ if (f.startsWith("watch-board.") && f.endsWith(".json")) {
1885
+ const content = JSON.parse(readFileSync(join(dir, f), "utf8"));
1886
+ if (content && typeof content === "object" && matches(content))
1887
+ return true;
1888
+ }
1889
+ }
1890
+ }
1891
+ }
1892
+ catch (error) {
1893
+ // Ownership uncertainty fails closed: abort this best-effort reconcile before it can treat a
1894
+ // recorded watch as an ordinary title-keyed worker. The outer reconcile boundary remains
1895
+ // cosmetic, but no close is attempted from a partial supervision-directory read.
1896
+ throw new OrcaError("list", `watch ownership unreadable in ${cwd}: ${errorText(error)}`, "");
1897
+ }
1898
+ return false;
1899
+ }
1900
+ async isRecordedWorkerHandle(handle, from, runtimeId) {
1901
+ // Do not use the driver's in-memory slots as the boundary: reconcile is also responsible for
1902
+ // terminals made before this driver process started. `create()` writes this proof before its
1903
+ // worker payload, and checkoutProven reads it from Orca rather than trusting a fixture handle.
1904
+ return (await this.checkoutProven(handle, undefined, from, runtimeId)).proven;
1905
+ }
1235
1906
  // tickmarkr's own createWorktree stays the sole checkout authority — orca never makes worktrees.
1236
1907
  async worktree(repo, branch, baseRef) {
1237
1908
  const worktree = await createWorktree(repo, branch, baseRef);