spexcode 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/anchors.ts +300 -0
  7. package/spec-cli/src/attach.ts +2 -2
  8. package/spec-cli/src/cli.ts +691 -536
  9. package/spec-cli/src/client.ts +31 -30
  10. package/spec-cli/src/contract-filter.ts +1 -1
  11. package/spec-cli/src/doctor.ts +40 -13
  12. package/spec-cli/src/gateway.ts +11 -7
  13. package/spec-cli/src/git.ts +23 -15
  14. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  15. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  16. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  17. package/spec-cli/src/graphStream.ts +288 -0
  18. package/spec-cli/src/guide.ts +142 -98
  19. package/spec-cli/src/harness-select.ts +2 -2
  20. package/spec-cli/src/harness.ts +30 -14
  21. package/spec-cli/src/help.ts +300 -385
  22. package/spec-cli/src/hooks.ts +1 -1
  23. package/spec-cli/src/index.ts +144 -103
  24. package/spec-cli/src/init.ts +14 -13
  25. package/spec-cli/src/issues.ts +89 -33
  26. package/spec-cli/src/layout.ts +5 -5
  27. package/spec-cli/src/lint.ts +128 -30
  28. package/spec-cli/src/localIssues.ts +61 -60
  29. package/spec-cli/src/materialize.ts +1 -1
  30. package/spec-cli/src/mentions.ts +15 -15
  31. package/spec-cli/src/migrate-table.ts +398 -0
  32. package/spec-cli/src/migrate.ts +386 -0
  33. package/spec-cli/src/ranker.ts +30 -4
  34. package/spec-cli/src/reaper.ts +117 -0
  35. package/spec-cli/src/search.bench.mjs +12 -12
  36. package/spec-cli/src/search.ts +1 -1
  37. package/spec-cli/src/sessions.ts +244 -138
  38. package/spec-cli/src/specs.ts +40 -25
  39. package/spec-cli/src/supervise.ts +2 -2
  40. package/spec-cli/src/tree.ts +5 -5
  41. package/spec-cli/src/uninstall.ts +4 -4
  42. package/spec-cli/templates/hooks/post-checkout +1 -1
  43. package/spec-cli/templates/hooks/post-merge +3 -3
  44. package/spec-cli/templates/hooks/pre-commit +9 -9
  45. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  51. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  52. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
  56. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  59. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  60. package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
  61. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  62. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  63. package/spec-cli/templates/spec/project/spec.md +1 -1
  64. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
  65. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  66. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
  67. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
  68. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
  69. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
  70. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
  71. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
  74. package/spec-dashboard/dist/index.html +2 -2
  75. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  76. package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
  77. package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
  78. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  79. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  80. package/spec-eval/src/humanok.ts +43 -0
  81. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  82. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  83. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  84. package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
  85. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  86. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  87. package/spec-forge/src/cli.ts +14 -13
  88. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  89. package/spec-cli/src/boardStream.ts +0 -179
  90. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
  91. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  92. package/spec-cli/templates/spec/project/.config/spec.md +0 -15
  93. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  94. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  98. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  99. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  100. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  101. /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
@@ -40,7 +40,7 @@ import { stripRefSigil } from './mentions.js'
40
40
  // queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
41
41
  // (closed = the worktree AND the global record are removed; not a stored status)
42
42
  // The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible — nothing
43
- // auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (reopen)
43
+ // auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (resume)
44
44
  // deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
45
45
  //
46
46
  // Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
@@ -114,7 +114,7 @@ export type Session = {
114
114
  id: string; node: string | null; branch: string | null; path: string
115
115
  label: string; headline: string // the DERIVED display strings ([[session-label]]) — the only names surfaces read
116
116
  raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
117
- parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex new` ran inside another session, else null; the frontend folds a child under it at read time
117
+ parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
118
118
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
119
119
  launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
120
120
  lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
@@ -332,37 +332,104 @@ async function procSnapshot(): Promise<ProcTable> {
332
332
  }
333
333
  return t
334
334
  }
335
- // @@@ LiveSnap - the ONE liveness snapshot the whole session list shares. `windows` = our live tmux windows
336
- // (id PaneProbe) + one whole-box process table; `sockets` = the ids whose rendezvous socket has a LIVE
337
- // LISTENER (connect-probed once here, not the file-exists lie — [[harness-adapter]]); `unproven` = the ids whose
338
- // LISTENER probe could not conclude (timeout under load / EAGAIN off a full-but-alive backlog see
339
- // rendezvousListening's tri-state)death UNPROVEN, so those rows read `unknown`, never `offline`;
340
- // `probeFailed` = the tmux window probe itself FAILED (timed out under load), which is DISTINCT from "tmux up,
341
- // no sessions"the former means death is UNPROVEN so those rows read `unknown`, the latter is authoritative
342
- // and reads `offline`.
343
- export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; sockets: Set<string>; unproven: Set<string> }
335
+ // @@@ LiveSnap - the ONE liveness snapshot the whole session list shares, built from a SINGLE tmux spawn
336
+ // (`list-panes -a` yields every session's window presence, pane pid, AND pane title at once every session has
337
+ // ≥1 pane). `windows` = our live tmux windows (id PaneProbe: pane pid + the hot-tier `pidAlive` verdict + the
338
+ // legacy `procs` table when a pid-less codex session needs it); `titles` = each pane's RAW title (harness-
339
+ // interpreted later in paneActivity claude: a self-authored task summary; codex: a spinner + the cwd folder
340
+ // name); `sockets` = the ids whose rendezvous socket has a LIVE LISTENER (connect-probed once here, not the
341
+ // file-exists lie[[harness-adapter]]); `unproven` = the ids whose LISTENER probe could not conclude (timeout
342
+ // under load / EAGAIN off a full-but-alive backlog — see rendezvousListening's tri-state) — death UNPROVEN, so
343
+ // those rows read `unknown`, never `offline`; `probeFailed` = the tmux probe itself FAILED (timed out under
344
+ // load), DISTINCT from "tmux up, no sessions" — the former means death is UNPROVEN so those rows read `unknown`,
345
+ // the latter is authoritative and reads `offline`.
346
+ export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
347
+
348
+ // @@@ parseLivePanes - the pure parser for the SINGLE merged `list-panes -a -F
349
+ // '#{session_name}\t#{pane_pid}\t#{pane_title}'` snapshot: id → { panePid, title }. First pane per session wins
350
+ // (our sessions are single-pane by construction). The title is the remainder AFTER the 2nd tab, so a title that
351
+ // itself contains tabs survives intact. Exported so the parse is unit-auditable without a tmux spawn.
352
+ export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
353
+ const m = new Map<string, { panePid?: number; title?: string }>()
354
+ for (const line of out.split('\n')) {
355
+ if (!line) continue
356
+ const t1 = line.indexOf('\t')
357
+ const name = (t1 < 0 ? line : line.slice(0, t1)).trim()
358
+ if (!name || m.has(name)) continue // first pane per session wins
359
+ if (t1 < 0) { m.set(name, {}); continue }
360
+ const rest = line.slice(t1 + 1)
361
+ const t2 = rest.indexOf('\t')
362
+ const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim())
363
+ const title = t2 < 0 ? '' : rest.slice(t2 + 1)
364
+ m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined })
365
+ }
366
+ return m
367
+ }
368
+
369
+ // @@@ agent.pid hot registry - the per-session death-latch backing BOTH hotSignature (the 100ms tier) and
370
+ // liveSnapshot's codex `pidAlive` verdict, so ONE latch rule serves both. keyed by session id → the last
371
+ // agent.pid { mtime, pid, deadLatched }. agentAlive statSyncs the pid file: missing → undefined (a
372
+ // pre-registration/old session — the warm tier / legacy tree-walk covers it); mtime changed → a relaunch wrote
373
+ // a fresh pid, so re-read + RESET the latch; then kill-0 the pid — alive (or EPERM) = true, ESRCH = false and
374
+ // LATCH permanently for this (pid, mtime) so a later pid-reuse by an unrelated process can never resurrect a
375
+ // dead session (the portable pid-reuse guard: death is irreversible per registration; only a NEW agent.pid
376
+ // write — a fresh mtime — resets). Sync fs + one kill-0 syscall, NO child process, so it stays honest under a
377
+ // thrashed event loop — exactly when a spawn-based probe would hang.
378
+ type PidEntry = { mtimeMs: number; pid: number; deadLatched: boolean }
379
+ const pidRegistry = new Map<string, PidEntry>()
380
+ function readAgentPid(p: string): number { try { return Number(readFileSync(p, 'utf8').trim()) } catch { return NaN } }
381
+ function agentAlive(id: string): boolean | undefined {
382
+ const pidPath = sessionArtifactPath(id, 'agent.pid')
383
+ let mtimeMs: number
384
+ try { mtimeMs = statSync(pidPath).mtimeMs } catch { pidRegistry.delete(id); return undefined } // no pid file → pre-registration
385
+ let e = pidRegistry.get(id)
386
+ if (!e || e.mtimeMs !== mtimeMs) { e = { mtimeMs, pid: readAgentPid(pidPath), deadLatched: false }; pidRegistry.set(id, e) }
387
+ if (e.deadLatched) return false // latched dead stays dead until a new write (fresh mtime)
388
+ if (!Number.isFinite(e.pid) || e.pid <= 0) return false
389
+ try { process.kill(e.pid, 0); return true }
390
+ catch (err) {
391
+ if ((err as NodeJS.ErrnoException).code === 'EPERM') return true // alive but not ours to signal
392
+ e.deadLatched = true // ESRCH → proven dead, latch it permanently
393
+ return false
394
+ }
395
+ }
396
+
397
+ // @@@ needsCodexProcScan - the legacy ps-scan GATE, factored PURE so it is assertable. The whole-box `ps`
398
+ // (procSnapshot) is paid ONLY when a windowed CODEX session has NO registered agent.pid (a pre-registration
399
+ // launch that still needs the paneTreeRunsCodex tree-walk). A box with no codex, or all pid-registered
400
+ // launches, returns false → zero ps spawn. Self-extinguishes as pre-registration sessions close.
401
+ export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean }[]): boolean {
402
+ return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
403
+ }
404
+
344
405
  async function liveSnapshot(): Promise<LiveSnap> {
345
406
  const windows = new Map<string, PaneProbe>()
407
+ const titles = new Map<string, string>()
346
408
  let out: string
347
409
  try {
348
- out = await tmux(['list-sessions', '-F', '#{session_name}\t#{pane_pid}'], TMUX_PROBE_TIMEOUT_MS)
410
+ // ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
411
+ out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_pid}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS)
349
412
  } catch (e) {
350
413
  // a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
351
414
  // non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
352
- return { probeFailed: probeTimedOut(e), windows, sockets: new Set(), unproven: new Set() }
415
+ return { probeFailed: probeTimedOut(e), windows, titles, sockets: new Set(), unproven: new Set() }
353
416
  }
354
- const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
355
- for (const line of out.split('\n')) {
356
- const tab = line.indexOf('\t'); if (tab < 0) { const name = line.trim(); if (name) windows.set(name, { procs }); continue }
357
- const name = line.slice(0, tab).trim(); if (!name) continue
358
- const pid = Number(line.slice(tab + 1).trim())
359
- windows.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, procs })
417
+ // the hot-tier pid verdict per windowed session (latch-consistent with hotSignature) + the legacy-scan gate.
418
+ const legacy: { harness: string; hasPid: boolean }[] = []
419
+ for (const [id, p] of parseLivePanes(out)) {
420
+ windows.set(id, { panePid: p.panePid, pidAlive: agentAlive(id) })
421
+ if (p.title) titles.set(id, p.title)
422
+ if (windows.get(id)!.pidAlive === undefined) { const rec = readRecord(id); if (rec) legacy.push({ harness: rec.harness, hasPid: false }) }
423
+ }
424
+ // the whole-box ps table is gathered ONCE, and ONLY for the legacy pid-less-codex fallback (paneTreeRunsCodex).
425
+ if (needsCodexProcScan(legacy)) {
426
+ const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
427
+ if (procs) for (const probe of windows.values()) probe.procs = procs
360
428
  }
361
- // LISTENER probe for every windowed session, once, in parallel (tooth: a live listener, not a lingering
362
- // socket file). A codex session has no rvSock → instant ENOENT → proven dead for the socket axis (codex
363
- // ignores it anyway). The tri-state matters here: 'unproven' (timeout/EAGAIN — a wedged or thrashed but
364
- // possibly-alive listener) lands in `unproven`, never silently in the not-live bucket, so liveness() can
365
- // render it `unknown` instead of a false `offline` (issue #40's load-spike graveyard).
429
+ // LISTENER probe for every windowed session, once, in parallel (a live listener, not a lingering socket
430
+ // file). A codex session has no rvSock → instant ENOENT → proven dead for the socket axis (codex ignores it).
431
+ // The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
432
+ // in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
366
433
  const ids = [...windows.keys()]
367
434
  const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
368
435
  const sockets = new Set<string>()
@@ -371,39 +438,43 @@ async function liveSnapshot(): Promise<LiveSnap> {
371
438
  if (listening[i] === 'live') sockets.add(id)
372
439
  else if (listening[i] === 'unproven') unproven.add(id)
373
440
  })
374
- return { probeFailed: false, windows, sockets, unproven }
375
- }
376
-
377
- // @@@ paneTitles - every session pane's RAW tmux title, free from tmux. The worker launches one pane per
378
- // session, named with the session id, so ONE `list-panes -a` maps id its raw `#{pane_title}`. Same shape
379
- // and cost as liveTmux (one tmux call for the whole list); failure empty map, so a tmux hiccup just drops
380
- // the subtitle for a tick, never the session. The raw title is NOT yet a headline — what a pane title MEANS
381
- // is harness-specific (claude: a self-authored task summary; codex: a spinner + the cwd folder name), so the
382
- // id→harness gating + glyph parse happens per session in paneActivity, not here.
383
- async function paneTitles(): Promise<Map<string, string>> {
384
- const m = new Map<string, string>()
385
- let out = ''
386
- try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS) } catch { return m }
387
- for (const line of out.split('\n')) {
388
- const tab = line.indexOf('\t'); if (tab < 0) continue
389
- const id = line.slice(0, tab), title = line.slice(tab + 1)
390
- if (id && title) m.set(id, title)
441
+ return { probeFailed: false, windows, titles, sockets, unproven }
442
+ }
443
+
444
+ // @@@ hotSignature - the 100ms zero-spawn death detector ([[state]] hot tier). NO child processes, NO async
445
+ // socket connects sync fs + one kill-0 syscall per session, so it stays honest under a thrashed event loop
446
+ // (exactly when a spawn-based probe would hang). The id list is refreshed LAZILY (at most once/second) from
447
+ // listSessionIds; per call each id's verdict comes from agentAlive (the shared death-latch registry). A session
448
+ // with NO agent.pid (pre-registration/old) is SKIPPED here the warm tier covers it. The fingerprint is the
449
+ // sorted `${id}:${alive?1:0}` pairs plus the id set, so it moves the instant a registered agent dies.
450
+ let hotIds: string[] = []
451
+ let hotIdsAt = 0
452
+ export async function hotSignature(): Promise<string> {
453
+ const now = Date.now()
454
+ if (now - hotIdsAt >= 1000) { hotIds = listSessionIds(); hotIdsAt = now }
455
+ const pairs: string[] = []
456
+ const present: string[] = []
457
+ for (const id of hotIds) {
458
+ const alive = agentAlive(id)
459
+ if (alive === undefined) continue // no agent.pid → the warm tier's concern, not the hot death detector
460
+ present.push(id)
461
+ pairs.push(`${id}:${alive ? 1 : 0}`)
391
462
  }
392
- return m
393
- }
394
-
395
- // @@@ sessionSignature - a CHEAP fingerprint of the two live board signals the session-store fs-watch can't
396
- // see, because they are tmux-derived, not file writes: LIVENESS (which sessions exist — a crash/offline) and
397
- // ACTIVITY (each pane's self-summary title). Two tmux calls, NO git and NO store walk, so [[board-stream]] can
398
- // poll this to push a `board-changed` the instant a worker dies or updates its headline, instead of the
399
- // dashboard waiting for its slow cold-path fallback. Sorted so it only moves on a real change.
400
- export async function sessionSignature(): Promise<string> {
401
- const [snap, titles] = await Promise.all([liveSnapshot(), paneTitles()])
402
- // fold in probe-failure, the live-listener set AND the unproven set so a socket dying (claude exit), the
403
- // probe flipping to unknown, or a listener wedging (unproven) pushes a board-changed immediately, not only
404
- // on window churn.
463
+ // prune latch entries for ids no longer registered (closed sessions), keeping the registry bounded.
464
+ const live = new Set(hotIds)
465
+ for (const k of [...pidRegistry.keys()]) if (!live.has(k)) pidRegistry.delete(k)
466
+ return pairs.sort().join(',') + '|' + present.sort().join(',')
467
+ }
468
+
469
+ // @@@ warmSignature - the 1s tier ([[state]] warm tier): the SINGLE merged tmux snapshot (windows + pane pids +
470
+ // titles) plus the rendezvous listener tri-state, fingerprinted so a socket dying (claude exit), the probe
471
+ // flipping to unknown, a listener wedging (unproven), or a headline changing pushes a board-changed the instant
472
+ // it happens not on window churn alone. Sorted so it only moves on a real change; NO git, NO extra store walk.
473
+ export async function warmSignature(): Promise<string> {
474
+ const snap = await liveSnapshot()
405
475
  return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
406
- [...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' + [...titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
476
+ [...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' +
477
+ [...snap.titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
407
478
  }
408
479
 
409
480
  // @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
@@ -487,7 +558,7 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
487
558
  // show through: awaiting → its proposal label; parked/error/asking/queued → themselves; active/idle → their
488
559
  // liveness (offline/starting/unknown), else the active-only idle/working inference (the mark-active hook flips
489
560
  // idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
490
- // terminal-mount and the relaunch panel on; this label is for badges and `spex ls`.
561
+ // terminal-mount and the relaunch panel on; this label is for badges and `spex session ls`.
491
562
  function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
492
563
  if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
493
564
  if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
@@ -498,7 +569,7 @@ function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
498
569
 
499
570
  // resolve a session id to its record + worktree. Now a DIRECT store read (the record carries worktree_path),
500
571
  // not a scan of every worktree reading its `.session` — O(1) and exact. null when the id has no governed-or-not
501
- // record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/reopen/merge/close/…) are unchanged.
572
+ // record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/resume/merge/close/…) are unchanged.
502
573
  async function findWorktree(id: string): Promise<{ path: string; branch: string | null; rec: SessRec } | null> {
503
574
  const rec = readRecord(id)
504
575
  if (!rec) return null
@@ -557,17 +628,17 @@ const lastKnownSession = new Map<string, Session>()
557
628
  // non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
558
629
  // Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
559
630
  export async function listSessions(): Promise<Session[]> {
560
- // ONE store enumeration + ONE tmux liveness snapshot + ONE pane-title snapshot for the whole list (all
561
- // independent), then every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
562
- const [ids, snap, titles] = await Promise.all([
563
- Promise.resolve(listSessionIds()), liveSnapshot(), paneTitles(),
631
+ // ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
632
+ // every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
633
+ const [ids, snap] = await Promise.all([
634
+ Promise.resolve(listSessionIds()), liveSnapshot(),
564
635
  ])
565
636
  const rows = ids.map((id) => guardSession(id, () => {
566
637
  const rec = readRecord(id)
567
638
  if (!rec || !rec.governed) { lastKnownSession.delete(id); return null } // no record, or a self-launched (non-board) one
568
639
  // the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
569
640
  // is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
570
- const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), titles.get(id))
641
+ const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id))
571
642
  const s = toSession(rec, reconcile(rec, snap), liveness(rec, snap), activity)
572
643
  lastKnownSession.set(id, s)
573
644
  return s
@@ -581,7 +652,7 @@ export async function listSessions(): Promise<Session[]> {
581
652
  const liveIds = new Set(ids)
582
653
  for (const k of [...lastKnownSession.keys()]) if (!liveIds.has(k)) lastKnownSession.delete(k)
583
654
  // @@@ creation order - order by birth (oldest first): each session keeps its slot for life and a new one
584
- // simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex ls`).
655
+ // simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex session ls`).
585
656
  // `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
586
657
  // overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
587
658
  // same-instant births (or sort-keys) stay deterministic.
@@ -597,18 +668,18 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
597
668
  }
598
669
 
599
670
  // @@@ session graph = LIVE monitors, not a stored relationship. An edge A→B means "agent A is RIGHT NOW
600
- // running `spex watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
601
- // persisted subscription. When a `spex watch` process starts it registers here and heartbeats; the edge
671
+ // running `spex session watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
672
+ // persisted subscription. When a `spex session watch` process starts it registers here and heartbeats; the edge
602
673
  // exists ONLY while that watch runs (deregistered on exit, dropped on a missed heartbeat). Single owner:
603
- // this in-memory map in the SERVER process — the watch process (a separate `spex watch`) talks to it over
604
- // HTTP (POST /api/sessions/graph/watch + …/unwatch). No datastore, no file: a backend restart starts
674
+ // this in-memory map in the SERVER process — the watch process (a separate `spex session watch`) talks to it over
675
+ // HTTP (POST /api/sessions/edges/watch + …/unwatch). No datastore, no file: a backend restart starts
605
676
  // empty and live watches re-register on their next heartbeat. Kept isolated from the board assembler.
606
677
  // an edge is either a LIVE monitor arrow (A→B = A watches B, directed) or a recorded comms link (A↔B =
607
678
  // they have exchanged `count` direct messages, undirected). The dashboard renders the two kinds apart.
608
679
  export type Edge = { from: string; to: string; kind: 'monitor' | 'comms'; count?: number }
609
680
 
610
681
  // @@@ comms log - direct agent talk ([[comms-edge]]), recorded per-worktree. `spex session send` goes
611
- // THROUGH the backend (sendKeys); on a delivered message that carries a sender, the backend appends one
682
+ // THROUGH the backend (sendText); on a delivered message that carries a sender, the backend appends one
612
683
  // {peer, ts} line to the RECIPIENT's comms log — each message counted exactly once, on the side the backend
613
684
  // already resolved. Persisted (survives a backend restart, unlike the in-memory monitor registrations) and
614
685
  // untracked, in the session's GLOBAL store dir (`comms.ndjson`, keyed by session_id) — it dies with the
@@ -631,7 +702,7 @@ function readComms(id: string): string[] {
631
702
  .map((l) => { try { return String(JSON.parse(l).peer || '') } catch { return '' } }).filter(Boolean)
632
703
  } catch { return [] }
633
704
  }
634
- // keyed by an opaque per-watch token (one per `spex watch` process), so a single agent may run several
705
+ // keyed by an opaque per-watch token (one per `spex session watch` process), so a single agent may run several
635
706
  // monitors without them clobbering each other. `selectors` is what the watch targets (resolved LIVE at
636
707
  // read time, not frozen here); empty / @all = a GLOBAL watcher. `expires` is the heartbeat backstop.
637
708
  type WatchReg = { watcher: string; selectors: string[]; expires: number }
@@ -644,7 +715,7 @@ export function registerWatch(token: string, watcher: string, selectors: string[
644
715
  watches.set(token, { watcher, selectors: selectors.filter(Boolean), expires: Date.now() + Math.max(1000, ttlMs) })
645
716
  return true
646
717
  }
647
- // deregister a watch (its `spex watch` exited); false if the token wasn't registered.
718
+ // deregister a watch (its `spex session watch` exited); false if the token wasn't registered.
648
719
  export function deregisterWatch(token: string): boolean { return watches.delete(token) }
649
720
  // the still-live registrations, pruning any whose heartbeat lapsed — the backstop for a watch that died
650
721
  // without a clean unwatch (SIGKILL, a dropped connection, a backend that was down at exit time).
@@ -765,13 +836,13 @@ let apiBaseMemo: Promise<ApiBaseInfo> | null = null
765
836
  export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
766
837
  export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
767
838
 
768
- // @@@ watch registration (CLIENT side) - a `spex watch` process is separate from the server, so it
839
+ // @@@ watch registration (CLIENT side) - a `spex session watch` process is separate from the server, so it
769
840
  // REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
770
841
  // deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
771
842
  // streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
772
843
  // the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
773
844
  // Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
774
- // re-exported here so callers keep one name. Used by `spex watch` + the agent-typed `spex session …`
845
+ // re-exported here so callers keep one name. Used by `spex session watch` + the agent-typed `spex session …`
775
846
  // declarations; the hooks instead pass `--session <id>` from the payload, so they never depend on this.
776
847
  export const ownSessionId = envSessionId
777
848
 
@@ -798,8 +869,8 @@ async function postJSON(path: string, body: unknown): Promise<void> {
798
869
  } catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
799
870
  }
800
871
  export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
801
- postJSON('/api/sessions/graph/watch', { token, watcher, selectors, ttlMs })
802
- export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/graph/unwatch', { token })
872
+ postJSON('/api/sessions/edges/watch', { token, watcher, selectors, ttlMs })
873
+ export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/edges/unwatch', { token })
803
874
 
804
875
  // @@@ isBackendDown - a `client.ts` BackendError surfacing in the watch poll loop (whose session
805
876
  // `source` is the HTTP backend client). Matched by NAME, not `instanceof`, so sessions.ts never imports
@@ -810,7 +881,7 @@ export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.na
810
881
  // @@@ isBackendUnreachable - the TRANSIENT subset of isBackendDown: the fetch itself failed (nothing
811
882
  // listening — ECONNREFUSED / "fetch failed"), which client.ts throws as a BackendError with NO HTTP
812
883
  // `status`. An HTTP BackendError (the backend answered non-2xx) DOES carry a status and is a real error, not
813
- // a momentary blip. The distinction matters to `spex wait`: a supervisor's backgrounded wait must survive
884
+ // a momentary blip. The distinction matters to `spex session wait`: a supervisor's backgrounded wait must survive
814
885
  // the ~1s window where the supervisor reboots its hot-reloaded child behind the stable port, retrying until
815
886
  // the backend answers again or the deadline hits — never dying on the in-flight fetch that a sibling merge's
816
887
  // restart happens to interrupt. Read via a structural cast (no client.ts import — that would be a cycle).
@@ -831,7 +902,7 @@ export const slugify = (s: string | null) =>
831
902
  // the FIRST `[[<id>]]` topic reference ([[mentions]]: `[[node]]` is a topic, `@` is now an actor/session).
832
903
  // When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
833
904
  // The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
834
- // (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
905
+ // (`.plugins`) keeps the dot — without `\.?` here `[[.plugins]]` captures nothing and never resolves to a node.
835
906
  // Token chars are ANY unicode letter/number (slugify's already-made choice): a CJK dir name is a legal node
836
907
  // id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
837
908
  // node-agnostic.
@@ -870,6 +941,9 @@ export function launcherCmd(rec: SessRec): string | undefined {
870
941
  if (rec.launchCmd) return rec.launchCmd
871
942
  return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
872
943
  }
944
+ // @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
945
+ // invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
946
+ const shq1 = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
873
947
  export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
874
948
  const file = join(storeDir(id), 'launch.sh')
875
949
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
@@ -878,6 +952,16 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
878
952
  // `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
879
953
  // ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
880
954
  const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
955
+ // @@@ birth registration - record the AGENT's real pid BEFORE exec, the anchor of the 100ms hot death tier
956
+ // ([[state]]). Each attempt runs `sh -c '<pid-write>; exec env <invocation>'`: the sh writes its own `$$` to
957
+ // agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
958
+ // (claude: env→(reclaude→)claude; codex: env→bash -lc <script> whose last line is `exec codex … resume`), and
959
+ // `$$` therefore IS the launched agent's pid. `env` carries the leading `VAR=val` assignments (an env prefix
960
+ // can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shq1) so the
961
+ // invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
962
+ // parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
963
+ const pidPath = join(storeDir(id), 'agent.pid')
964
+ const born = `sh -c ${shq1(`printf %s "$$" > ${shq1(pidPath)}; exec env ${invocation}`)}`
881
965
  // Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
882
966
  // ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
883
967
  // has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
@@ -888,7 +972,7 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
888
972
  writeFileSync(file, [
889
973
  `for __spex_try in 1 2 3; do`,
890
974
  ` __spex_t0=$SECONDS`,
891
- ` ${invocation}`,
975
+ ` ${born}`,
892
976
  ` __spex_rc=$?`,
893
977
  ` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
894
978
  ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
@@ -1003,7 +1087,7 @@ export function superviseQueue(intervalMs = 3000): void {
1003
1087
  // the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
1004
1088
  // another repo's backend) silently lands the write in the WRONG repo. Read/control-READS deliberately
1005
1089
  // point anywhere (viewer-points-anywhere, see remote-client); every MUTATING verb (new/merge/send/close/
1006
- // rename/rawkey/reopen/exit) is bound to the caller's project. So before writing, compare the caller's
1090
+ // rename/input/resume/stop) is bound to the caller's project. So before writing, compare the caller's
1007
1091
  // repo root to the backend's served root and FAIL LOUD on a provable, same-host mismatch — never a silent
1008
1092
  // misroute. An explicit `--api`/`--port` flag SKIPS the guard: the flag is the one provably-deliberate
1009
1093
  // cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
@@ -1016,8 +1100,8 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1016
1100
  try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
1017
1101
  let served: string | null = null
1018
1102
  try {
1019
- const r = await fetch(`${url}/api/layout`)
1020
- if (r.ok) served = (await r.json() as { main?: string }).main ?? null
1103
+ const r = await fetch(`${url}/api/settings`)
1104
+ if (r.ok) served = (await r.json() as { layout?: { main?: string } }).layout?.main ?? null
1021
1105
  } catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
1022
1106
  if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
1023
1107
  let backendMain: string
@@ -1032,19 +1116,19 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1032
1116
  }
1033
1117
  }
1034
1118
 
1035
- // @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
1119
+ // @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
1036
1120
  // BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
1037
- // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex new` (e.g. a supervisor) would
1121
+ // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
1038
1122
  // bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
1039
1123
  // comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
1040
1124
  // either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
1041
1125
  // reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
1042
1126
  // handler calls newSession directly, so it never re-enters this path.
1043
1127
  export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
1044
- await assertProjectMatch('spex new')
1128
+ await assertProjectMatch('spex session new')
1045
1129
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
1046
1130
  // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
1047
- // backend, whose process env carries no acting session id. An agent that runs `spex new` stamps its own id;
1131
+ // backend, whose process env carries no acting session id. An agent that runs `spex session new` stamps its own id;
1048
1132
  // a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
1049
1133
  const parent = ownSessionId()
1050
1134
  let res: Response
@@ -1168,7 +1252,7 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
1168
1252
  // genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
1169
1253
  // deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
1170
1254
  const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
1171
- // waitForReady (slot-hold + reopen) waits through a daemon-race retry
1255
+ // waitForReady (slot-hold + resume) waits through a daemon-race retry
1172
1256
  // instead of returning before a recovering socket
1173
1257
  const SOCKET_POLL_MS = 200
1174
1258
  async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<boolean> {
@@ -1182,11 +1266,11 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
1182
1266
  }
1183
1267
  }
1184
1268
 
1185
- // @@@ reopen - bring the agent back up and settle its RESTING lifecycle. THREE rules:
1269
+ // @@@ resumeSession - bring the agent back up and settle its RESTING lifecycle. THREE rules:
1186
1270
  // • RESUME GUARD ([[state]]): a relaunch KILLS the running agent (`kill-session` + fresh window), so it is a
1187
1271
  // data-loss operation the moment the agent is actually ALIVE — the incident's kill-shot was restore-on-alive
1188
- // (the board LIED offline, the human relaunched, live workers died mid-work). So reopen re-derives the
1189
- // agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session reopen`,
1272
+ // (the board LIED offline, the human relaunched, live workers died mid-work). So resume re-derives the
1273
+ // agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session resume`,
1190
1274
  // `guard` default true), REFUSES LOUD rather than relaunch a live agent — you steer a live agent by
1191
1275
  // MESSAGING it, not by restoring it. Death must be PROVEN: an `unknown` probe (tmux timed out under load —
1192
1276
  // the exact condition that started the incident) also refuses, since a live worker can't be ruled out. A
@@ -1200,11 +1284,11 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
1200
1284
  // immediately after (mergeSession's merge) addresses a LIVE agent, not a racing boot.
1201
1285
  // • lifecycle: the SAME active-only guard markIdle uses — a resumed agent that was WORKING (`active`) is now
1202
1286
  // just sitting at its prompt → `idle`; EVERY deliberate declaration survives untouched (`awaiting` + its
1203
- // proposal, `asking`, `parked`, `error`, `queued`). reopen does NOT touch the `proposal` — resuming a
1287
+ // proposal, `asking`, `parked`, `error`, `queued`). resume does NOT touch the `proposal` — resuming a
1204
1288
  // session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
1205
1289
  // a refusal leaves the record wholly untouched.
1206
1290
  // Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
1207
- export async function reopen(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
1291
+ export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
1208
1292
  const { force = false, guard = true } = opts
1209
1293
  const wt = await findWorktree(id)
1210
1294
  if (!wt) return { ok: false, error: `no such session ${id}` }
@@ -1312,7 +1396,7 @@ function porcelainPath(line: string): string {
1312
1396
  // whether uncommitted non-runtime work remains, the merge/lint gates, and the agent's standing proposal.
1313
1397
  // ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); lint reflects the CLI
1314
1398
  // package's OWN location (where this runs) — the spec-cli that's actually live. There is deliberately NO
1315
- // build/typecheck/test gate: whether a change is SOUND is proven by the node's yatsu (measured through the
1399
+ // build/typecheck/test gate: whether a change is SOUND is proven by the node's eval scenarios (measured through the
1316
1400
  // real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
1317
1401
  // the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
1318
1402
  // has that id.
@@ -1332,7 +1416,7 @@ export type ReviewPayload = {
1332
1416
 
1333
1417
  // @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
1334
1418
  // (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
1335
- // it on every reviewPayload — i.e. on every [[review-proof]] Proof-tab open, and once per session — is
1419
+ // it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
1336
1420
  // wasteful, so memoize it on a whole-repo fingerprint: `rev-parse HEAD` + `status --porcelain` + the mtimes of
1337
1421
  // the changed paths (covers committed state, the dirty SET, and dirty-file CONTENT). An identical fingerprint
1338
1422
  // reuses the last (in-flight) result — a re-open or a second session's proof is instant — while any commit or
@@ -1415,10 +1499,10 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
1415
1499
 
1416
1500
  // @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
1417
1501
  // SESSION'S OWN agent lands the merge, never the server (it carries no `git merge` logic and never touches
1418
- // main's tree). It reopens the session (clears the proposal, `--resume`s via reopen if tmux died —
1502
+ // main's tree). It resumes the session (clears the proposal, `--resume`s via resumeSession if tmux died —
1419
1503
  // which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
1420
- // — that delivered prompt flips the lifecycle to active regardless of reopen's resting state
1421
- // through sendKeys. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
1504
+ // — that delivered prompt flips the lifecycle to active regardless of resume's resting state
1505
+ // through sendText. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
1422
1506
  // the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
1423
1507
  // CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
1424
1508
  // re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
@@ -1428,16 +1512,16 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
1428
1512
  const branch = wt.branch, main = mainRoot()
1429
1513
  // ensure-live, NOT the guarded human relaunch: an already-online agent is reused (the merge prompt just needs
1430
1514
  // a live socket), and only a confirmed-offline one is relaunched — so merge never refuses on a live agent.
1431
- const re = await reopen(id, { guard: false })
1432
- if (!re.ok) return { dispatched: false, reason: re.error || 'could not reopen session' }
1515
+ const re = await resumeSession(id, { guard: false })
1516
+ if (!re.ok) return { dispatched: false, reason: re.error || 'could not resume session' }
1433
1517
  const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
1434
1518
  const reason = subject.replace(/^spec:\s+/, '') || branch
1435
- const r = await sendKeys(id, mergePrompt(main, branch, reason))
1519
+ const r = await sendText(id, mergePrompt(main, branch, reason))
1436
1520
  if (!r.ok) return { dispatched: false, reason: r.error }
1437
1521
  return { dispatched: true }
1438
1522
  }
1439
1523
 
1440
- // @@@ stopAgentProcess - the shared teardown both exit and close begin with, so there is ONE kill path, not
1524
+ // @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
1441
1525
  // two: kill the agent's tmux client, drop its boot-window stamp (else a just-launched id lingers in the grace
1442
1526
  // window reading `starting` instead of `offline`), and sweep its rendezvous socket. The socket lives in the OS
1443
1527
  // tmpdir (NOT the worktree), so worktree removal alone would leave it behind — closing many sessions over time
@@ -1449,22 +1533,22 @@ async function stopAgentProcess(id: string): Promise<void> {
1449
1533
  try { rmSync(rvSock(id), { force: true }) } catch { /* best-effort sweep; tmpdir socket, claude/OS may already be gone */ }
1450
1534
  }
1451
1535
 
1452
- // @@@ exitSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
1536
+ // @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
1453
1537
  // worktree + branch + transcript intact. The session stays on the board, now reading `offline` (no tmux window)
1454
- // whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation (see reopen). This is
1538
+ // whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation (see resumeSession). This is
1455
1539
  // "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
1456
1540
  // the freed capacity drains a queued session next (drainQueue).
1457
- export async function exitSession(id: string): Promise<boolean> {
1541
+ export async function stopSession(id: string): Promise<boolean> {
1458
1542
  const wt = await findWorktree(id)
1459
1543
  await stopAgentProcess(id)
1460
- void drainQueue() // an exit frees a slot — start the next queued session if any
1544
+ void drainQueue() // a stop frees a slot — start the next queued session if any
1461
1545
  return !!wt
1462
1546
  }
1463
1547
 
1464
- // @@@ closeSession - the REMOVAL (human-confirmed): exit's soft stop PLUS removing the worktree + branch AND
1548
+ // @@@ closeSession - the REMOVAL (human-confirmed): stop's soft kill PLUS removing the worktree + branch AND
1465
1549
  // the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
1466
- // exitSession (no duplicate kill path), then the git worktree/branch teardown that exit deliberately skips,
1467
- // then the store sweep (exit KEEPS the record so the session stays on the board offline; close discards it).
1550
+ // stopSession (no duplicate kill path), then the git worktree/branch teardown that stop deliberately skips,
1551
+ // then the store sweep (stop KEEPS the record so the session stays on the board offline; close discards it).
1468
1552
  // The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
1469
1553
  // so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
1470
1554
  export async function closeSession(id: string): Promise<boolean> {
@@ -1483,7 +1567,7 @@ export async function closeSession(id: string): Promise<boolean> {
1483
1567
  }
1484
1568
 
1485
1569
  // @@@ captureSessionResult - the session's live pane as a one-shot snapshot (output), the server side of
1486
- // `GET /api/sessions/:id/capture` that `spex capture` (a backend client) reads. A monitoring read MUST
1570
+ // `GET /api/sessions/:id/capture` that `spex session show --capture` (a backend client) reads. A monitoring read MUST
1487
1571
  // distinguish "I failed to read" from "the pane is genuinely empty" — the old captureSession collapsed
1488
1572
  // unknown-id, offline, and capture-error all to `''`, indistinguishable from an empty pane (a blank screen
1489
1573
  // that exits 0 is worse than useless to a manager). So the result is DISCRIMINATED: an empty pane is a
@@ -1507,8 +1591,8 @@ export async function captureSessionResult(id: string): Promise<CaptureResult> {
1507
1591
  // transition — review / done / close-pending (agent proposals), offline (process died), error — and the
1508
1592
  // removal. Per Monitor's "silence is not success" rule a vanished session pings too. Net feed:
1509
1593
  // launched → [actionable transitions] → closed. Each line names the suggested next action(s). Drop into Monitor:
1510
- // Monitor({ command: 'spex watch', persistent: true, description: 'spex session state changes' })
1511
- // @@@ presentation + selection - shared by `spex ls` (pretty), `spex watch` (events) and the API.
1594
+ // Monitor({ command: 'spex session watch', persistent: true, description: 'session state changes' })
1595
+ // @@@ presentation + selection - shared by `spex session ls` (pretty), `spex session watch` (events) and the API.
1512
1596
  export const STATUS_GLYPH: Record<DisplayStatus, string> = {
1513
1597
  working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
1514
1598
  'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
@@ -1543,7 +1627,7 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
1543
1627
  }
1544
1628
 
1545
1629
  // @@@ resolveSession - resolve ONE selector to ONE session against a board: the single-target counterpart of
1546
- // selectSessions, for the control verbs (review/send/merge/close/reopen/capture/prompt). The backend matches
1630
+ // selectSessions, for the control verbs (review/send/merge/close/resume/show). The backend matches
1547
1631
  // ids EXACTLY, so a verb resolves the selector here first and then calls with the FULL id — a node/branch/
1548
1632
  // prefix selector drives a verb just as it filters `ls`. The result is DISCRIMINATED so a caller can fail
1549
1633
  // precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
@@ -1600,7 +1684,7 @@ export const NOTE_BOARD_LIMIT = 50
1600
1684
  const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
1601
1685
 
1602
1686
  // @@@ statusLegend - one-line glyph\u2192meaning key, BUILT from STATUS_GLYPH so it can never drift from
1603
- // the glyphs the table actually prints. Shown under `spex ls` so the symbols are self-explanatory.
1687
+ // the glyphs the table actually prints. Shown under `spex session ls` so the symbols are self-explanatory.
1604
1688
  export function statusLegend(color = true): string {
1605
1689
  const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
1606
1690
  const parts = (Object.keys(STATUS_GLYPH) as DisplayStatus[]).map(
@@ -1633,10 +1717,10 @@ const NEXT: Record<string, string> = {
1633
1717
  review: 'merge | close',
1634
1718
  done: 'merge | close',
1635
1719
  'close-pending': 'close',
1636
- offline: 'reopen (relaunch & resume)',
1637
- error: 'reopen (relaunch & retry) | capture | close',
1638
- asking: 'send "<msg>" | capture',
1639
- idle: 'send "<msg>" | capture',
1720
+ offline: 'resume (relaunch the same conversation)',
1721
+ error: 'resume (relaunch & retry) | show --capture | close',
1722
+ asking: 'send "<msg>" | show --capture',
1723
+ idle: 'send "<msg>" | show --capture',
1640
1724
  queued: 'waiting for a free slot — starts automatically | close',
1641
1725
  }
1642
1726
  export function sessionEvent(s: Session): string {
@@ -1646,32 +1730,41 @@ export function sessionEvent(s: Session): string {
1646
1730
  }
1647
1731
  // @@@ launchEvent - a session's FIRST sighting. A launch goes straight to 'working' (not actionable), so
1648
1732
  // without this the watch feed would be blind to new sessions starting. Emitted ONCE per id, regardless of
1649
- // status, so `spex watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
1733
+ // status, so `spex session watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
1650
1734
  export function launchEvent(s: Session): string {
1651
1735
  const note = s.note ? ` — note: ${s.note}` : ''
1652
1736
  const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
1653
1737
  return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
1654
1738
  }
1655
1739
  // @@@ source - the session board the poll reads. The CLI passes the BACKEND CLIENT (client.ts
1656
- // clientListSessions), so `spex watch` streams whatever backend SPEXCODE_API_URL points at — including a
1740
+ // clientListSessions), so `spex session watch` streams whatever backend SPEXCODE_API_URL points at — including a
1657
1741
  // REMOTE machine's. It is REQUIRED (no local default): a forgotten source must be a compile error, never a
1658
1742
  // silent in-process read of the wrong (local) board — the exact false-green the 2-machine test guards.
1659
- export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number } }
1660
- // @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex wait` runs on); a
1743
+ export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number; onObserved?: (status: DisplayStatus, previous: DisplayStatus | null) => void } }
1744
+ // @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex session wait` runs on); a
1661
1745
  // plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
1662
- // "block for a worker, then exit" that is GUARANTEED to return. The deadline is checked EVERY poll, before
1663
- // EVERY sleep (and even when a poll throws), so a target stuck in ANY non-actionable state
1664
- // (`working`/`parked`/`idle`/`queued`/`starting`) can never hang the callerit exits at the deadline.
1665
- // `reached` = the target hit an actionable status; the rest are the loud exits. `backendDown` is a verdict
1746
+ // "block for a worker's NEXT transition, then exit" that is GUARANTEED to return. Bounded mode is
1747
+ // EDGE-TRIGGERED ([[session-edges]]): it resolves only upon OBSERVING a watched target transition from a
1748
+ // non-actionable status INTO an actionable onean already-actionable first sighting is recorded and
1749
+ // narrated (`onObserved`, arrival first) but never resolves, so "wait for the dispatched merge to actually
1750
+ // land" has a real signal instead of an instant false return on the standing `review` level. Every observed
1751
+ // status per target accumulates into a `path` (arrival first); the resolving target's path rides the outcome
1752
+ // so the caller can print the whole observed sequence. The deadline is checked EVERY poll, before
1753
+ // EVERY sleep (and even when a poll throws), so a target that never produces an edge — stuck in ANY
1754
+ // non-actionable state (`working`/`parked`/`idle`/`queued`/`starting`), or sitting forever on an
1755
+ // already-actionable level — can never hang the caller: it exits at the deadline, path carried for the report.
1756
+ // `reached` = an observed edge into an actionable status; the rest are the loud exits. `backendDown` is a verdict
1666
1757
  // about the TRANSPORT, never the session — `kind` keeps its two shapes distinct for the caller's outcome
1667
1758
  // surface: 'unreachable' (nothing listening, the whole timeout was spent retrying) vs 'http' (reachable but
1668
1759
  // broken, failed loud at once). The caller must surface these OUTSIDE the session-status vocabulary — a
1669
1760
  // supervisor must never be able to read a transport failure as a session state (issue #40).
1670
- export type WatchOutcome = { reached: DisplayStatus } | { timedOut: true } | { gone: true } | { backendDown: string; kind: 'unreachable' | 'http' }
1761
+ export type WatchOutcome = { reached: DisplayStatus; path: DisplayStatus[] } | { timedOut: true; path: DisplayStatus[] } | { gone: true; path: DisplayStatus[] } | { backendDown: string; kind: 'unreachable' | 'http' }
1671
1762
  export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
1672
1763
  const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
1673
1764
  const tag = as ? `[${as}] ` : ''
1674
1765
  const prev = new Map<string, DisplayStatus>()
1766
+ const paths = new Map<string, DisplayStatus[]>() // bounded mode: every status observed per target, arrival first
1767
+ const anyPath = () => (paths.size ? [...paths.values()][0] : [])
1675
1768
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
1676
1769
  // the no-hang wall: a fixed deadline computed ONCE, checked unconditionally every iteration below.
1677
1770
  const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
@@ -1689,10 +1782,23 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1689
1782
  warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
1690
1783
  const ids = new Set(all.map((s) => s.id))
1691
1784
  const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
1785
+ let edge: { status: DisplayStatus; path: DisplayStatus[] } | null = null
1692
1786
  for (const s of all) {
1693
- if (!prev.has(s.id)) emit(tag + launchEvent(s)) // FIRST sighting → launched, any status (incl. 'working'), once
1694
- if (s.status === prev.get(s.id)) continue // only on transition, not every tick
1787
+ const was = prev.has(s.id) ? prev.get(s.id)! : null
1788
+ if (was === null) emit(tag + launchEvent(s)) // FIRST sighting → launched, any status (incl. 'working'), once
1789
+ if (s.status === was) continue // only on transition, not every tick
1695
1790
  prev.set(s.id, s.status)
1791
+ if (until) {
1792
+ const p = paths.get(s.id) ?? []
1793
+ p.push(s.status)
1794
+ paths.set(s.id, p)
1795
+ until.onObserved?.(s.status, was)
1796
+ // THE edge: a previously-observed NON-actionable status transitioning INTO an actionable one. An
1797
+ // actionable ARRIVAL (was === null) is deliberately not an edge — that standing level is what the
1798
+ // old wait false-returned on; and an actionable→actionable hop (review→done) isn't one either. The
1799
+ // rise out of non-actionable is the one signal that means "the target needs you AGAIN".
1800
+ if (was !== null && !isActionable(was) && isActionable(s.status)) edge = { status: s.status, path: p }
1801
+ }
1696
1802
  if (passesStatus(s.status) && (WATCH_ACTIONABLE.has(s.status) || (includeIdle && s.status === 'idle'))) emit(tag + sessionEvent(s))
1697
1803
  }
1698
1804
  // @@@ closed = the worktree is GONE. Because listSessions lists every EXISTING worktree (a flaky detail
@@ -1704,13 +1810,13 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1704
1810
  prev.delete(id)
1705
1811
  emit(`${tag}[spex] closed \u00b7 removed [id ${id}]`)
1706
1812
  }
1707
- // BOUNDED mode (`until`, what `spex wait` runs): return the moment a watched target is actionable; an empty selected set
1708
- // means the target is gone (absent from the board), which it can never come back from. Both sit inside
1709
- // the try, after the emit pass, so the caller still saw every transition before we hand control back.
1813
+ // BOUNDED mode (`until`, what `spex session wait` runs): return on an OBSERVED non-actionable→actionable
1814
+ // edge; an empty selected set means the target is gone (absent from the board), which it can never come
1815
+ // back from. Both sit inside the try, after the emit pass, so the caller still saw every transition
1816
+ // before we hand control back.
1710
1817
  if (until) {
1711
- const hit = all.find((s) => isActionable(s.status))
1712
- if (hit) return { reached: hit.status }
1713
- if (!all.length) return { gone: true }
1818
+ if (edge) return { reached: edge.status, path: edge.path }
1819
+ if (!all.length) return { gone: true, path: anyPath() }
1714
1820
  }
1715
1821
  } catch (e) {
1716
1822
  // a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
@@ -1720,7 +1826,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1720
1826
  // • UNREACHABLE (no status — ECONNREFUSED / fetch failed) — nothing is listening, e.g. the supervisor
1721
1827
  // is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
1722
1828
  // record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
1723
- // backgrounded `spex wait` survives the ~1s restart instead of dying on the interrupted fetch.
1829
+ // backgrounded `spex session wait` survives the ~1s restart instead of dying on the interrupted fetch.
1724
1830
  if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message, kind: 'http' }
1725
1831
  if (isBackendDown(e)) {
1726
1832
  downMsg = (e as Error).message
@@ -1728,22 +1834,22 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1728
1834
  }
1729
1835
  }
1730
1836
  // the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep: this
1731
- // guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE — nor spin forever on a
1837
+ // guarantees `spex session wait` can never hang on a worker that never produces an edge — nor spin forever on a
1732
1838
  // backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
1733
- // not a false "no actionable status" timeout, so the manager sees the honest cause.
1734
- if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true }
1839
+ // not a false "no edge" timeout, so the manager sees the honest cause.
1840
+ if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true, path: anyPath() }
1735
1841
  await sleep(intervalMs)
1736
1842
  }
1737
1843
  }
1738
1844
 
1739
- // @@@ sendKeys - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
1845
+ // @@@ sendText - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
1740
1846
  // ([[harness-adapter]]) — claude the rendezvous control socket (optimistic-after-liveness: the reply line flushes
1741
1847
  // to a live socket), codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
1742
1848
  // fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
1743
1849
  // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
1744
1850
  // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
1745
1851
  // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
1746
- export async function sendKeys(id: string, text: string, from?: string): Promise<DispatchResult> {
1852
+ export async function sendText(id: string, text: string, from?: string): Promise<DispatchResult> {
1747
1853
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
1748
1854
  const rec = readRecord(id)
1749
1855
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }