spexcode 0.6.1 → 0.6.2

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 (72) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
@@ -6,16 +6,18 @@ import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mk
6
6
  import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { seedWorktreeHostState } from './worktree-sources.js'
9
- import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, type ReviewDiffFile } from './git.js'
9
+ import { git, gitA, gitTry, isGitObjectId, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, type ReviewDiffFile } from './git.js'
10
10
  import { loadConfig, loadSpecs, loadSpecsLite, type ConfigPreset, type SpecLite } from './specs.js'
11
11
  import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type TurnFailure, type FailureSubscription, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
12
12
  import { materialize } from './materialize.js'
13
13
  import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
14
- import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
15
- import { drain, enqueue, owesDelivery } from './delivery-queue.js'
14
+ import { readSessionFiles } from './session-files.js'
15
+ import { readSessionWebs, type SessionWeb } from './session-web.js'
16
+ import { appendSent, recordStatus, lastHumanSendVia, sentDispatchReceipt, settleSentDispatch, type SentDispatchReceipt, type SentDispatchState } from './session-timeline.js'
17
+ import { drain, enqueue, ensurePendingWhileLocked, owesDelivery, pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, senderDeliveryRevoked, withDeliveryLocks, type PendingMessage } from './delivery-queue.js'
16
18
  import { stripRefSigil } from './mentions.js'
17
19
  import { shQuote } from './sh.js'
18
- import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
20
+ import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js'
19
21
  import { processStartToken } from './process-identity.js'
20
22
  import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js'
21
23
 
@@ -84,6 +86,40 @@ export type Session = {
84
86
  archiveHazard?: string | null // explicit legacy/invariant violation; never hidden as a clean archive
85
87
  prompt: string | null; promptPreview: string | null; created: number; activity: string | null
86
88
  sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
89
+ files?: string[] // live posted paths ([[files]]), read from the session store with the rest of the projection
90
+ web?: SessionWeb[] // live posted loopback services ([[web]]), read from the session store with the rest of the projection
91
+ }
92
+
93
+ // HTTP carries no authenticated session identity. A CLI may report its environment id, but that remains
94
+ // evidence supplied by the caller rather than authority over the target or a fact about who performed close.
95
+ export type CloseSource = { kind: 'unverified-session-claim'; id: string } | { kind: 'user' }
96
+
97
+ function normalizeCloseSource(raw: unknown): CloseSource {
98
+ if (raw == null) return { kind: 'user' }
99
+ if (!raw || typeof raw !== 'object') throw new ResourceConflict('refusing session close: source must be user or an unverified session claim')
100
+ const source = raw as { kind?: unknown; id?: unknown }
101
+ if (source.kind === 'user') return { kind: 'user' }
102
+ if (source.kind === 'unverified-session-claim' && typeof source.id === 'string' && source.id.trim())
103
+ return { kind: 'unverified-session-claim', id: source.id.trim() }
104
+ throw new ResourceConflict('refusing session close: source must be user or an unverified session claim')
105
+ }
106
+
107
+ function appendCloseLedger(id: string, rec: SessRec, source: CloseSource): void {
108
+ const path = join(runtimeRoot(), 'session-close-ledger.ndjson')
109
+ const event = {
110
+ version: 1,
111
+ action: 'close-authorized',
112
+ at: new Date().toISOString(),
113
+ source,
114
+ target: {
115
+ id,
116
+ harness: rec.harness,
117
+ thread: rec.harnessSessionId,
118
+ worktree: rec.worktreePath,
119
+ branch: rec.branch,
120
+ },
121
+ }
122
+ appendFileSync(path, `${JSON.stringify(event)}\n`)
87
123
  }
88
124
 
89
125
  function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
@@ -139,6 +175,9 @@ export const sessionHeadline = sessionTitle
139
175
  // execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
140
176
  // "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
141
177
  const TMUX_PROBE_TIMEOUT_MS = 4000
178
+ // A destructive close already names one target, so it can afford the longer bounded probe without making
179
+ // every dashboard refresh wait behind an overloaded tmux server.
180
+ const TARGET_PROBE_TIMEOUT_MS = 15000
142
181
  async function tmux(args: string[], timeoutMs?: number): Promise<string> {
143
182
  const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' as const } : {}) })
144
183
  return stdout
@@ -172,6 +211,7 @@ export type SessRec = {
172
211
  launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
173
212
  createRequestId?: string | null // digest of the public Idempotency-Key; binds retry without storing the bearer
174
213
  createPayloadHash?: string | null // exact normalized create payload bound to createRequestId
214
+ base?: string | null // explicit fork point the creator pinned; absent/null = the auto-detected source-of-truth branch
175
215
  launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
176
216
  }
177
217
  type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
@@ -299,10 +339,11 @@ async function withRecordLock<T>(id: string, body: () => Promise<T>, signal?: Ab
299
339
  const release = await acquireRecordLock(id, 30_000, signal)
300
340
  try { return await body() } finally { release() }
301
341
  }
302
- function withRecordLockSync<T>(id: string, body: () => T): T {
342
+ export function withSessionRecordLockSync<T>(id: string, body: () => T): T {
303
343
  const release = acquireRecordLockSync(id)
304
344
  try { return body() } finally { release() }
305
345
  }
346
+ const withRecordLockSync = withSessionRecordLockSync
306
347
  function tryRecordLockSync(id: string): (() => void) | null {
307
348
  mkdirSync(recordLockRoot(), { recursive: true })
308
349
  const path = recordLockPath(id)
@@ -366,6 +407,7 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
366
407
  launchOwner: launchOwner || null,
367
408
  createRequestId: raw.create_request_id || null,
368
409
  createPayloadHash: raw.create_payload_hash || null,
410
+ base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
369
411
  launchReadinessPending: pendingRaw ? {
370
412
  version: 1,
371
413
  startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
@@ -433,6 +475,9 @@ function writeRecord(rec: SessRec): void {
433
475
  launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
434
476
  create_request_id: rec.createRequestId ?? '',
435
477
  create_payload_hash: rec.createPayloadHash ?? '',
478
+ // Written only when the creator pinned one: an unpinned record keeps its exact legacy bytes, so a
479
+ // restore-the-frozen-record path stays byte-identical instead of silently gaining a key.
480
+ ...(rec.base ? { base: rec.base } : {}),
436
481
  launch_readiness_pending: rec.launchReadinessPending ? {
437
482
  version: 1,
438
483
  startedAt: rec.launchReadinessPending.startedAt,
@@ -567,22 +612,107 @@ export function cancelSessionWatch(watcher: string, targets: string[]): number {
567
612
  return cancelled
568
613
  }
569
614
 
615
+ export type SessionReparentResult = { children: string[]; parent: string; notified: string[] }
616
+
617
+ async function withRecordLocks<T>(ids: string[], body: () => Promise<T>, index = 0): Promise<T> {
618
+ if (index >= ids.length) return body()
619
+ return withRecordLock(ids[index], () => withRecordLocks(ids, body, index + 1))
620
+ }
621
+
622
+ function assertReparentable(children: string[], parent: string, records: Map<string, SessRec>): void {
623
+ if (!children.length) throw new ResourceConflict('reparent needs at least one child session')
624
+ managedWatchRecord(parent)
625
+ for (const id of children) {
626
+ const child = records.get(id)
627
+ if (!child?.governed) throw new ResourceConflict(`session ${id} is not a governed child session`)
628
+ if (id === parent) throw new ResourceConflict('a session cannot be its own parent')
629
+ }
630
+ const childIds = new Set(children)
631
+ const seen = new Set<string>()
632
+ for (let current: string | null = parent; current; ) {
633
+ if (childIds.has(current)) throw new ResourceConflict(`reparent would create a parent cycle through ${current}`)
634
+ if (seen.has(current)) throw new ResourceConflict(`cannot reparent through malformed parent cycle at ${current}`)
635
+ seen.add(current)
636
+ current = readRecord(current)?.parent ?? null
637
+ }
638
+ }
639
+
640
+ export async function reparentSessionRecords(rawChildren: string[], parent: string): Promise<SessionReparentResult> {
641
+ const children = [...new Set(rawChildren)].sort()
642
+ if (!parent) throw new ResourceConflict('reparent needs a destination parent session')
643
+ const notify: SessRec[] = []
644
+ await withRecordLock('session-reparent-transaction', async () => {
645
+ // Read former supervisors only after the transaction fence: a concurrent reparent may change exactly
646
+ // this relation, and its real sender lock is part of the next transaction's outgoing-message boundary.
647
+ const before = new Map(children.map((id) => [id, managedWatchRecord(id)]))
648
+ assertReparentable(children, parent, before)
649
+ const formerParents = [...new Set([...before.values()].flatMap((record) => record.parent ? [record.parent] : []))]
650
+ await withRecordLocks([...children, ...formerParents].sort(), () => withDeliveryLocks(children, async () => {
651
+ const current = new Map(children.map((id) => [id, managedWatchRecord(id)]))
652
+ assertReparentable(children, parent, current)
653
+ const snapshots = children.map((id) => ({ id, record: current.get(id)!, watchers: readWatchEntries(id), pending: pendingSnapshot(id) }))
654
+ try {
655
+ for (const snapshot of snapshots) {
656
+ const { record, watchers } = snapshot
657
+ const hadNewParent = watchers.some((entry) => entry.watcher === parent)
658
+ const retainedNewParent = watchers.find((entry) => entry.watcher === parent)
659
+ const nextWatchers = watchers.filter((entry) => entry.watcher !== record.parent && entry.watcher !== parent)
660
+ nextWatchers.push(retainedNewParent ?? { watcher: parent, createdAt: new Date().toISOString() })
661
+ writeWatchEntries(snapshot.id, nextWatchers)
662
+ if (record.parent !== parent) writeRecord({ ...record, parent })
663
+ if (record.parent !== parent || !hadNewParent) notify.push({ ...record, parent })
664
+ }
665
+ for (const snapshot of snapshots) {
666
+ if (snapshot.record.parent && snapshot.record.parent !== parent)
667
+ revokePendingFromWhileLocked(snapshot.id, snapshot.record.parent)
668
+ }
669
+ } catch (error) {
670
+ let rollbackFailure: unknown = null
671
+ for (const snapshot of [...snapshots].reverse()) {
672
+ try {
673
+ replacePendingWhileLocked(snapshot.id, snapshot.pending)
674
+ writeWatchEntries(snapshot.id, snapshot.watchers)
675
+ writeRecord(snapshot.record)
676
+ } catch (rollback) { rollbackFailure ??= rollback }
677
+ }
678
+ const detail = error instanceof Error ? error.message : String(error)
679
+ const rollbackDetail = rollbackFailure instanceof Error ? `; rollback also failed: ${rollbackFailure.message}` : ''
680
+ throw new ResourceConflict(`reparent did not commit: ${detail}${rollbackDetail}`)
681
+ }
682
+ }))
683
+ })
684
+ for (const child of notify) {
685
+ const delivered = await sendText(parent, watchMessage(child), child.session)
686
+ if (!delivered.ok) throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`)
687
+ }
688
+ return { children, parent, notified: notify.map((child) => child.session) }
689
+ }
690
+
570
691
  // Share one liveness snapshot rather than spawning tmux for every displayed session.
571
692
  export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
572
693
 
573
- // First pane per session wins; split only twice so titles may contain tabs.
694
+ // tmux rewrites CONTROL characters in a format string before printing them 3.6a turns both a tab and a raw
695
+ // 0x1f into `_`, while 3.4 turns a raw 0x1f into the printable escape `\037`. So the field separator is ASKED
696
+ // FOR as that printable text, which every supported version passes through untouched, and the format is built
697
+ // from the same constant the parser splits on: the two can no longer disagree about what tmux actually emits.
698
+ const TMUX_PANE_SEPARATOR = '\\037'
699
+ export const TMUX_PANE_FORMAT = `#{session_name}${TMUX_PANE_SEPARATOR}#{pane_pid}${TMUX_PANE_SEPARATOR}#{pane_title}`
700
+
701
+ // First pane per session wins; split only twice so titles may contain the field separator.
574
702
  export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
575
703
  const m = new Map<string, { panePid?: number; title?: string }>()
576
704
  for (const line of out.split('\n')) {
577
705
  if (!line) continue
578
- const t1 = line.indexOf('\t')
706
+ // Accept the former tab shape for callers replaying old snapshots; tmux itself emits TMUX_PANE_SEPARATOR.
707
+ const separator = line.includes(TMUX_PANE_SEPARATOR) ? TMUX_PANE_SEPARATOR : '\t'
708
+ const t1 = line.indexOf(separator)
579
709
  const name = (t1 < 0 ? line : line.slice(0, t1)).trim()
580
710
  if (!name || m.has(name)) continue // first pane per session wins
581
711
  if (t1 < 0) { m.set(name, {}); continue }
582
- const rest = line.slice(t1 + 1)
583
- const t2 = rest.indexOf('\t')
712
+ const rest = line.slice(t1 + separator.length)
713
+ const t2 = rest.indexOf(separator)
584
714
  const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim())
585
- const title = t2 < 0 ? '' : rest.slice(t2 + 1)
715
+ const title = t2 < 0 ? '' : rest.slice(t2 + separator.length)
586
716
  m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined })
587
717
  }
588
718
  return m
@@ -613,13 +743,17 @@ export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean
613
743
  return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
614
744
  }
615
745
 
616
- async function liveSnapshot(): Promise<LiveSnap> {
746
+ async function liveSnapshot(targetId?: string): Promise<LiveSnap> {
617
747
  const windows = new Map<string, PaneProbe>()
618
748
  const titles = new Map<string, string>()
619
749
  let out: string
620
750
  try {
621
751
  // ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
622
- out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_pid}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS)
752
+ // A target-scoped close probe avoids unrelated panes turning a safe close into a global timeout.
753
+ const args = targetId
754
+ ? ['list-panes', '-t', targetId, '-F', TMUX_PANE_FORMAT]
755
+ : ['list-panes', '-a', '-F', TMUX_PANE_FORMAT]
756
+ out = await tmux(args, targetId ? TARGET_PROBE_TIMEOUT_MS : TMUX_PROBE_TIMEOUT_MS)
623
757
  } catch (e) {
624
758
  // a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
625
759
  // non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
@@ -647,7 +781,12 @@ async function liveSnapshot(): Promise<LiveSnap> {
647
781
  // The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
648
782
  // in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
649
783
  const ids = [...windows.keys()]
650
- const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
784
+ // A burst of simultaneous Unix-socket connects can fill a Claude listener's accept backlog on macOS and
785
+ // turn every healthy socket into `unproven`. Keep the probe bounded while preserving the tri-state result.
786
+ const listening: Awaited<ReturnType<typeof rendezvousListening>>[] = []
787
+ for (let start = 0; start < ids.length; start += 2) {
788
+ listening.push(...await Promise.all(ids.slice(start, start + 2).map((id) => rendezvousListening(id))))
789
+ }
651
790
  const sockets = new Set<string>()
652
791
  const unproven = new Set<string>()
653
792
  ids.forEach((id, i) => {
@@ -801,7 +940,7 @@ function corruptSession(id: string, entry: { path: string; error: string }): Ses
801
940
  parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
802
941
  lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
803
942
  note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
804
- activity: null, sortKey: null, archiveHazard: null,
943
+ activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
805
944
  }
806
945
  }
807
946
 
@@ -814,7 +953,7 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
814
953
  const pp = prompt ? oneLinePreview(prompt) : null
815
954
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp }
816
955
  const harness = harnessById(rec.harness || defaultHarness.id)
817
- return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
956
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session) }
818
957
  }
819
958
 
820
959
  export async function renameSession(id: string, name: string): Promise<boolean> {
@@ -846,6 +985,14 @@ export async function sessionPrompt(id: string): Promise<string | null> {
846
985
  // Preserve rows through a transient record-read failure; prune after the store entry disappears.
847
986
  const lastKnownSession = new Map<string, Session>()
848
987
 
988
+ // A BOARD row carries the launch ask only as its one-line preview. The full text is served by the
989
+ // id-addressed record detail, which reads the stored prompt itself and overrides this field — so the list
990
+ // never had a reader for it. Shipping it made the body grow with total ask LENGTH instead of session count
991
+ // (measured on the adopter-a board: 2192 KB of a 2218 KB default body, 27 KB of which was actual board data)
992
+ // and pinned the same bytes in `lastKnownSession` for the life of the process. The CREATE response keeps the
993
+ // full text: it is a receipt for one ask the caller just made, not a row in a list of many.
994
+ const boardRow = (s: Session): Session => { s.prompt = null; return s }
995
+
849
996
  export async function listSessions(includeArchived = false): Promise<Session[]> {
850
997
  // ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
851
998
  // every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
@@ -893,7 +1040,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
893
1040
  // A forced public liveness comes only from the shared record projection. Do not let live process/thread
894
1041
  // evidence punch through it (including archive hazard repair).
895
1042
  if (entry.kind === 'ok' && entry.liveness === 'offline') {
896
- const pending = toSession(rec, 'offline', 'offline')
1043
+ const pending = boardRow(toSession(rec, 'offline', 'offline'))
897
1044
  lastKnownSession.set(id, pending)
898
1045
  return pending
899
1046
  }
@@ -917,7 +1064,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
917
1064
  const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true)
918
1065
  const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec
919
1066
  const projectedLv = projected === rec ? liveness(rec, snap) : physical!
920
- const s = toSession(projected, reconcile(projected, snap), projectedLv, activity)
1067
+ const s = boardRow(toSession(projected, reconcile(projected, snap), projectedLv, activity))
921
1068
  if (projected !== rec) s.archiveHazard = changedDuringCensus.has(id)
922
1069
  ? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
923
1070
  : hasValidColdProof(rec)
@@ -956,10 +1103,16 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
956
1103
  export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
957
1104
  export type ApiBaseInfo = { url: string; source: ApiBaseSource }
958
1105
  const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
1106
+
1107
+ export function optionArgv(argv: readonly string[] = process.argv): readonly string[] {
1108
+ const delimiter = argv.indexOf('--')
1109
+ return delimiter < 0 ? argv : argv.slice(0, delimiter)
1110
+ }
1111
+
959
1112
  // the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
960
1113
  // `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
961
1114
  function explicitApiFlag(): string | null {
962
- const argv = process.argv
1115
+ const argv = optionArgv()
963
1116
  const ai = argv.indexOf('--api')
964
1117
  if (ai >= 0) {
965
1118
  const v = argv[ai + 1]
@@ -1536,7 +1689,7 @@ export class SessionCreateError extends Error {
1536
1689
  this.name = 'SessionCreateError'
1537
1690
  }
1538
1691
  }
1539
- type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal }
1692
+ type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal; base?: string | null }
1540
1693
  type SessionCreateRequestOptions = {
1541
1694
  requestKey?: string
1542
1695
  signal?: AbortSignal
@@ -1585,12 +1738,16 @@ function throwIfCreateAborted(signal: AbortSignal, phase: SessionCreatePhase): v
1585
1738
  export async function sessionCreateRequest(body: unknown, options: SessionCreateRequestOptions = {}): Promise<SessionCreateRequestResult> {
1586
1739
  if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
1587
1740
  const input = body as Record<string, unknown>
1588
- const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
1741
+ const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher', 'name', 'base'].includes(key)).sort()
1589
1742
  if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
1590
1743
  const prompt = typeof input.prompt === 'string' ? input.prompt : ''
1591
1744
  if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
1592
1745
  const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
1593
1746
  const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
1747
+ if (input.name !== undefined && typeof input.name !== 'string') return { status: 400, error: 'session-create name must be a string' }
1748
+ const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim() : null
1749
+ if (input.base !== undefined && typeof input.base !== 'string') return { status: 400, error: 'session-create base must be a string' }
1750
+ const base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
1594
1751
  let key: string
1595
1752
  try { key = normalizeCreateKey(options.requestKey) }
1596
1753
  catch (error) {
@@ -1599,7 +1756,10 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
1599
1756
  }
1600
1757
  const requestDigest = digest(key)
1601
1758
  const id = sessionIdForCreateKey(key)
1602
- const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null }))
1759
+ // Keep no-name retries byte-compatible with pre-name receipts; an explicit non-empty name is one more
1760
+ // immutable creation input because it publishes the record's existing display override. `base` joins them
1761
+ // for the same reason and with the same shape: absent, it must not perturb an existing receipt's bytes.
1762
+ const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null, ...(name ? { name } : {}), ...(base ? { base } : {}) }))
1603
1763
  const controller = new AbortController()
1604
1764
  const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408))
1605
1765
  if (options.signal?.aborted) cancel()
@@ -1609,7 +1769,7 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
1609
1769
  traceSessionCreate(id, requestDigest, 'request', 'start')
1610
1770
  try {
1611
1771
  try {
1612
- const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
1772
+ const session = await prepareSession(prompt, parent, launcher, name, { id, requestDigest, payloadHash, base, signal: controller.signal })
1613
1773
  traceSessionCreate(id, requestDigest, 'request', 'finish')
1614
1774
  return { status: 201, session }
1615
1775
  } catch (error) {
@@ -1658,15 +1818,16 @@ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean
1658
1818
  return false
1659
1819
  } finally { clearTimeout(timer) }
1660
1820
  }
1661
- export async function createSession(prompt: string, launcher?: string): Promise<Session> {
1821
+ export async function createSession(prompt: string, launcher?: string, name?: string, base?: string): Promise<Session> {
1662
1822
  const parent = ownSessionId()
1663
1823
  const requestKey = randomUUID()
1824
+ const body = { prompt, parent, launcher, ...(name !== undefined ? { name } : {}), ...(base !== undefined ? { base } : {}) }
1664
1825
  const target = await apiBaseInfo()
1665
- const base = target.url
1826
+ const apiUrl = target.url
1666
1827
  const refused = await probeSessionCreateAuthority(target)
1667
1828
  if (refused) {
1668
1829
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
1669
- const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey })
1830
+ const fallback = await sessionCreateRequest(body, { requestKey })
1670
1831
  if (fallback.status === 201) return fallback.session
1671
1832
  const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
1672
1833
  error.name = 'BackendError'
@@ -1677,10 +1838,10 @@ export async function createSession(prompt: string, launcher?: string): Promise<
1677
1838
  timer.unref?.()
1678
1839
  let res: Response
1679
1840
  try {
1680
- res = await fetch(`${base}/api/sessions`, {
1841
+ res = await fetch(`${apiUrl}/api/sessions`, {
1681
1842
  method: 'POST',
1682
1843
  headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
1683
- body: JSON.stringify({ prompt, parent, launcher }),
1844
+ body: JSON.stringify(body),
1684
1845
  signal: controller.signal,
1685
1846
  })
1686
1847
  } catch (error) {
@@ -1944,8 +2105,8 @@ async function proveSessionCandidate(path: string, branch: string, signal: Abort
1944
2105
  return null
1945
2106
  }
1946
2107
 
1947
- async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
1948
- const { id, requestDigest, payloadHash, signal } = context
2108
+ async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, name: string | null, context: SessionCreateContext): Promise<Session> {
2109
+ const { id, requestDigest, payloadHash, base, signal } = context
1949
2110
  let phase: SessionCreatePhase = 'creation-lock'
1950
2111
  let shouldDrain = false
1951
2112
  traceSessionCreate(id, requestDigest, phase, 'start')
@@ -1984,6 +2145,16 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1984
2145
  const title = ref ? null : titleFromPrompt(rawPrompt)
1985
2146
  const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1986
2147
  const root = mainRoot()
2148
+ // An explicit base pins the fork point so a run is reproducible against a frozen commit instead of
2149
+ // whatever the source-of-truth branch has drifted to. Resolve it here, before any git mutation: an
2150
+ // unknown ref must fail the create request outright, never leave a half-made worktree behind.
2151
+ const startPoint = base ?? mainBranch()
2152
+ if (base) {
2153
+ const resolved = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]))
2154
+ if (!resolved.ok || !resolved.stdout.trim()) {
2155
+ throw new SessionCreateError('session_create_failed', phase, `session-create base does not name a commit: ${base}`, 400)
2156
+ }
2157
+ }
1987
2158
  const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`
1988
2159
  const path = join(root, '.worktrees', slug)
1989
2160
  const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined
@@ -2041,7 +2212,7 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
2041
2212
  gitMutationStarted = true
2042
2213
  traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add')
2043
2214
  const added = await withGitAbortSignal(signal, () => gitTry(
2044
- ['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()],
2215
+ ['-C', root, 'worktree', 'add', '-b', branch, path, startPoint],
2045
2216
  { extraEnv: DEFER_FOOTPRINT_REFRESH },
2046
2217
  ))
2047
2218
  traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add')
@@ -2058,10 +2229,11 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
2058
2229
 
2059
2230
  let rec: SessRec = {
2060
2231
  session: id, governed: true, worktreePath: path, branch,
2061
- node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
2232
+ node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
2062
2233
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
2063
2234
  harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
2064
2235
  launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
2236
+ ...(base ? { base } : {}),
2065
2237
  }
2066
2238
  owned.store = true
2067
2239
  const dir = storeDir(id)
@@ -2411,6 +2583,8 @@ export type ReviewGates = {
2411
2583
  }
2412
2584
  export type ReviewPayload = {
2413
2585
  id: string; node: string | null; branch: string | null
2586
+ branchHead: string // immutable session-branch object whose committed review facts describe
2587
+ baseHead: string // immutable canonical-base object from the same ref snapshot
2414
2588
  label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
2415
2589
  ahead: number // commits the node branch is ahead of main
2416
2590
  dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
@@ -2419,6 +2593,23 @@ export type ReviewPayload = {
2419
2593
  proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
2420
2594
  }
2421
2595
 
2596
+ type ReviewHeadPair = { branchHead: string; baseHead: string }
2597
+
2598
+ async function reviewHeadPair(root: string, branch: string, base: string): Promise<ReviewHeadPair> {
2599
+ const branchRef = `refs/heads/${branch}`, baseRef = `refs/heads/${base}`
2600
+ const output = await gitA(['-C', root, 'for-each-ref', '--sort=refname', '--format=%(refname)%00%(objectname)', branchRef, baseRef])
2601
+ const refs = new Map<string, string>()
2602
+ for (const line of output.split('\n')) {
2603
+ const at = line.indexOf('\0')
2604
+ if (at > 0) refs.set(line.slice(0, at), line.slice(at + 1).trim())
2605
+ }
2606
+ const branchHead = refs.get(branchRef), baseHead = refs.get(baseRef)
2607
+ if (!branchHead || !baseHead || !isGitObjectId(root, branchHead) || !isGitObjectId(root, baseHead)) {
2608
+ throw new ResourceConflict(`review head pair is unproven: ${branchRef} or ${baseRef} is missing or not a native Git object id`)
2609
+ }
2610
+ return { branchHead, baseHead }
2611
+ }
2612
+
2422
2613
  // @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
2423
2614
  // (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
2424
2615
  // it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
@@ -2465,19 +2656,28 @@ async function lintGate(): Promise<ReviewGates['lint']> {
2465
2656
  export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2466
2657
  const wt = await findWorktree(id)
2467
2658
  if (!wt) return null
2659
+ if (!wt.rec.governed || !wt.branch) throw new ResourceConflict(`session ${id} has no governed branch to review`)
2468
2660
  const base = mainBranch()
2661
+ const { branchHead, baseHead } = await reviewHeadPair(wt.path, wt.branch, base)
2469
2662
  const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
2470
- gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
2663
+ gitA(['-C', wt.path, 'rev-list', '--count', `${baseHead}..${branchHead}`]),
2471
2664
  gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
2472
- mergeBaseDiff(wt.path, base),
2473
- mergeConflicts(wt.path, base),
2665
+ mergeBaseDiff(wt.path, baseHead, branchHead),
2666
+ mergeConflicts(wt.path, baseHead, branchHead),
2474
2667
  lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
2475
2668
  ])
2669
+ const settledPair = await reviewHeadPair(wt.path, wt.branch, base)
2670
+ if (settledPair.branchHead !== branchHead || settledPair.baseHead !== baseHead) {
2671
+ throw new ResourceConflict(
2672
+ `review head pair changed while assembling: started branch ${branchHead} / base ${baseHead}, ended branch ${settledPair.branchHead} / base ${settledPair.baseHead}`,
2673
+ 'session_review_head_changed',
2674
+ )
2675
+ }
2476
2676
  // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
2477
2677
  // path is genuine work — this is just the total uncommitted count.
2478
2678
  const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
2479
2679
  return {
2480
- id, node: wt.rec.node, branch: wt.branch,
2680
+ id, node: wt.rec.node, branch: wt.branch, branchHead, baseHead,
2481
2681
  label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
2482
2682
  ahead: Number(aheadOut.trim()) || 0,
2483
2683
  dirtyNonRuntime, diff,
@@ -2486,32 +2686,204 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2486
2686
  }
2487
2687
  }
2488
2688
 
2489
- function mergePrompt(mainPath: string, branch: string, reason: string): string {
2490
- const base = mainBranch()
2689
+ // @@@ shAscii - a 7-bit ASCII shell word yielding EXACTLY these bytes. A dispatched prompt crosses a control
2690
+ // channel, the agent's own tool call, and a terminal before a shell parses it, and a byte above 0x7F can be
2691
+ // dropped, replaced by U+FFFD, or truncate the line at any of those hops — the measured field failure. Bytes
2692
+ // that are already safe ASCII stay literal (an ASCII repo's prompt is byte-identical to before); anything
2693
+ // else is rebuilt from POSIX `printf %b` octal escapes, taken from the RAW bytes with no normalization.
2694
+ function shAscii(value: string | Buffer): string {
2695
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')
2696
+ const safe = (b: number) => b >= 0x20 && b <= 0x7e
2697
+ if (bytes.every(safe)) return shQuote(bytes.toString('latin1'))
2698
+ let escaped = ''
2699
+ for (const b of bytes) escaped += safe(b) && b !== 0x27 && b !== 0x5c ? String.fromCharCode(b) : `\\0${b.toString(8).padStart(3, '0')}`
2700
+ return `"$(printf '%b' '${escaped}')"`
2701
+ }
2702
+ const hexBytes = (value: string | Buffer): string => (Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')).toString('hex')
2703
+
2704
+ // @@@ mergePrompt - every gate states its own verdict, in ASCII. Two things were wrong with a bare `&&` chain
2705
+ // of `test`s. It is silent on BOTH outcomes, so an agent shown "no output" cannot tell a held gate from a
2706
+ // broken one and conservatively refuses to land forever; and it compared unicode ref/path bytes as shell
2707
+ // strings, which only works if every hop to the executor carried them intact. So each item now reports its
2708
+ // own actual-vs-expected value, only the whole conjunction emits the success token, and every value that can
2709
+ // carry a byte above 0x7F travels as hex (compared straight off the pipe, before any shell string layer) or
2710
+ // as a printf-escaped literal. The conjunction itself is unchanged: all five, resp. three, checks together.
2711
+ function mergePrompt(mainPath: string, worktreePath: string, worktreeTop: Buffer, branch: string, base: string, expectedBranchHead: string, expectedBaseHead: string, reason: string): string {
2712
+ const mainQ = shQuote(mainPath)
2713
+ const mainA = shAscii(mainPath), worktreeA = shAscii(worktreePath)
2714
+ const refA = shAscii(`refs/heads/${branch}`), baseRefA = shAscii(`refs/heads/${base}`), baseA = shAscii(base)
2715
+ const topHexQ = shQuote(hexBytes(worktreeTop)), refHexQ = shQuote(hexBytes(`refs/heads/${branch}`))
2716
+ const reviewedQ = shQuote(expectedBranchHead), baseHeadQ = shQuote(expectedBaseHead)
2717
+ const messageA = shAscii(`merge ${branch}: ${reason}`)
2718
+ const hexPipe = `| tr -d '\\n' | od -An -tx1 | tr -d ' \\n'`
2491
2719
  return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself — in YOUR OWN worktree, never in the shared ${base} checkout.\n\n` +
2720
+ `0. Re-prove the REVIEWED generation BEFORE changing anything. All five checks must hold together, and the block SAYS SO: reading \`REVIEWED_GENERATION_OK\` is the ONLY pass. A \`REVIEWED_GENERATION_FAIL <n>/<item>\` line — or no output at all — is a FAIL: STOP, report the stale review naming that item, and do not sync or land. (Detached HEAD, another checked-out branch, a moved/missing branch/base ref, or any OID outside the reviewed pair each surface as one of the five items.) Run the block AS WRITTEN — it is pure ASCII on purpose, and items 1 and 2 compare hex so a retyped or re-encoded copy cannot change what they mean:\n` +
2721
+ ` wt=${worktreeA}; main_ck=${mainA}; ref=${refA}; base_ref=${baseRefA}\n` +
2722
+ ` want_top=${topHexQ}; want_ref=${refHexQ}; want_reviewed=${reviewedQ}; want_base=${baseHeadQ}\n` +
2723
+ ` g1=$(git -C "$wt" rev-parse --show-toplevel ${hexPipe}); test "$g1" = "$want_top" || echo "REVIEWED_GENERATION_FAIL 1/toplevel: hex [$g1] != [$want_top]"\n` +
2724
+ ` g2=$(git -C "$wt" symbolic-ref --quiet HEAD ${hexPipe}); test "$g2" = "$want_ref" || echo "REVIEWED_GENERATION_FAIL 2/symbolic: hex [$g2] != [$want_ref]"\n` +
2725
+ ` g3=$(git -C "$wt" rev-parse HEAD); test "$g3" = "$want_reviewed" || echo "REVIEWED_GENERATION_FAIL 3/wtHEAD: [$g3] != [$want_reviewed]"\n` +
2726
+ ` g4=$(git -C "$main_ck" show-ref --verify --hash "$ref"); test "$g4" = "$want_reviewed" || echo "REVIEWED_GENERATION_FAIL 4/mainref: [$g4] != [$want_reviewed]"\n` +
2727
+ ` g5=$(git -C "$main_ck" show-ref --verify --hash "$base_ref"); test "$g5" = "$want_base" || echo "REVIEWED_GENERATION_FAIL 5/baseref: [$g5] != [$want_base]"\n` +
2728
+ ` test "$g1" = "$want_top" && test "$g2" = "$want_ref" && test "$g3" = "$want_reviewed" && test "$g4" = "$want_reviewed" && test "$g5" = "$want_base" && echo REVIEWED_GENERATION_OK\n` +
2492
2729
  `1. Sync first, where you work: \`git merge ${base}\` INTO your branch, resolve every conflict here, and re-run what proves your work. The ${base} checkout is the fleet's ONE landing door — a merge that stops to ask about conflicts holds it for everyone.\n` +
2493
- `2. Land only a TRIVIAL merge: \`git -C ${mainPath} merge-base --is-ancestor ${base} ${branch}\` must exit 0 (your branch already contains ${base})then\n git -C ${mainPath} merge --no-ff -m "merge ${branch}: ${reason}" ${branch}\n If that check fails, ${base} moved while you tested: go back to step 1 instead of landing.\n` +
2730
+ `2. Freeze the TESTED result immediately before landing and merge that exact object, never a moving branch name. Same rules as step 0 run it as written, and \`LANDING_MERGED <oid>\` is the ONLY proof it landed; it prints only after all three guards held AND the merge itself succeeded:\n` +
2731
+ ` wt=${worktreeA}; main_ck=${mainA}; ref=${refA}; base_br=${baseA}; msg=${messageA}; want_ref=${refHexQ}\n` +
2732
+ ` candidate=$(git -C "$wt" rev-parse HEAD)\n` +
2733
+ ` c1=$(git -C "$wt" symbolic-ref --quiet HEAD ${hexPipe}); test "$c1" = "$want_ref" || echo "LANDING_FAIL 1/symbolic: hex [$c1] != [$want_ref]"\n` +
2734
+ ` c2=$(git -C "$main_ck" show-ref --verify --hash "$ref"); test "$c2" = "$candidate" || echo "LANDING_FAIL 2/mainref: [$c2] != [$candidate]"\n` +
2735
+ ` c3=0; git -C "$main_ck" merge-base --is-ancestor "$base_br" "$candidate" || { c3=1; echo "LANDING_FAIL 3/ancestor: [$candidate] does not contain the base branch; go back to step 1"; }\n` +
2736
+ ` test "$c1" = "$want_ref" && test "$c2" = "$candidate" && test "$c3" = 0 && git -C "$main_ck" merge --no-ff -m "$msg" "$candidate" && echo "LANDING_MERGED $candidate"\n` +
2737
+ ` No \`LANDING_MERGED\` line means nothing landed: a \`LANDING_FAIL\` names which guard, and its absence after the guards held means the merge itself failed. Either way ${base} or the branch moved while you tested — go back to step 0/review instead of landing.\n` +
2494
2738
  `3. A busy door is a wait, not a race: if the ${base} checkout is already mid-merge (an unresolved index), retry with a bounded wait — never abort or resolve someone else's in-progress merge. ` +
2495
- `4. Verify it landed: \`${base}\`'s HEAD must now be the new merge commit and no merge may be left in progress — if YOUR merge went half-merged, run \`git -C ${mainPath} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
2739
+ `4. Verify it landed: \`${base}\`'s HEAD must now be the new merge commit and no merge may be left in progress — if YOUR merge went half-merged, run \`git -C ${mainQ} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
2496
2740
  `5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
2497
2741
  }
2498
2742
 
2499
- async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean; reason?: string }> {
2743
+ export type MergeSessionResult =
2744
+ | { dispatched: true; replayed?: boolean; expectedBranchHead: string; expectedBaseHead: string }
2745
+ | { dispatched: false; reason: string; code?: 'session_merge_invalid_request' | 'session_merge_key_reused' | 'session_merge_head_changed' | 'session_merge_branch_unproven' | 'session_merge_not_proposed'; status?: 400 | 409 }
2746
+ export type MergeSessionOptions = { requestKey?: string; expectedBranchHead?: string; expectedBaseHead?: string }
2747
+
2748
+ function normalizeMergeKey(raw: string | undefined): string | null {
2749
+ if (raw === undefined) return null
2750
+ const key = raw.trim()
2751
+ if (!key || key.length > 128 || !/^[\x21-\x7e]+$/.test(key)) return null
2752
+ return key
2753
+ }
2754
+
2755
+ async function proveMergeBranchIdentity(worktreePath: string, branch: string, base: string): Promise<{ ok: true; branchHead: string; baseHead: string; top: string } | { ok: false; reason: string }> {
2756
+ const [top, symbolic, head, pair] = await Promise.all([
2757
+ gitTry(['-C', worktreePath, 'rev-parse', '--show-toplevel']),
2758
+ gitTry(['-C', worktreePath, 'symbolic-ref', '--quiet', '--short', 'HEAD']),
2759
+ gitTry(['-C', worktreePath, 'rev-parse', 'HEAD']),
2760
+ reviewHeadPair(worktreePath, branch, base).then((value) => ({ ok: true as const, value }), (error) => ({ ok: false as const, error })),
2761
+ ])
2762
+ if (!top.ok || !symbolic.ok || !head.ok || !pair.ok) {
2763
+ return { ok: false, reason: `branch identity is unreadable${!symbolic.ok ? ' (worktree HEAD is detached or not symbolic)' : ''}` }
2764
+ }
2765
+ let actualTop = top.stdout.trim(), expectedTop = worktreePath
2766
+ try { actualTop = realpathSync(actualTop) } catch { /* comparison reports the missing/moved root */ }
2767
+ try { expectedTop = realpathSync(expectedTop) } catch { /* comparison reports the missing/moved root */ }
2768
+ if (actualTop !== expectedTop) return { ok: false, reason: `worktree top-level is ${actualTop}, expected ${expectedTop}` }
2769
+ if (symbolic.stdout.trim() !== branch) return { ok: false, reason: `worktree checked out ${symbolic.stdout.trim() || '(detached)'}, expected ${branch}` }
2770
+ const worktreeHead = head.stdout.trim()
2771
+ if (worktreeHead !== pair.value.branchHead) return { ok: false, reason: `worktree HEAD ${worktreeHead} does not match stored branch ${branch} at ${pair.value.branchHead}` }
2772
+ return { ok: true, ...pair.value, top: actualTop }
2773
+ }
2774
+
2775
+ type DispatchDelivery = NonNullable<SentDispatchReceipt['delivery']>
2776
+ function keyedPendingMessage(receipt: SentDispatchReceipt, mid: string, delivery: DispatchDelivery): PendingMessage {
2777
+ return {
2778
+ mid,
2779
+ text: delivery.text,
2780
+ from: delivery.from,
2781
+ dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
2782
+ }
2783
+ }
2784
+
2785
+ async function acceptedMergeDispatch(id: string, idempotency: SentDispatchReceipt): Promise<SentDispatchState | null> {
2786
+ return withRecordLock(id, async () => withDeliveryLocks([id], async () => {
2787
+ const prior = sentDispatchReceipt(id, idempotency.operation, idempotency.requestDigest)
2788
+ if (prior?.payloadHash === idempotency.payloadHash && prior.delivery && !prior.delivered) {
2789
+ ensurePendingWhileLocked(id, keyedPendingMessage(idempotency, prior.mid, prior.delivery))
2790
+ }
2791
+ return prior
2792
+ }))
2793
+ }
2794
+
2795
+ async function replayAcceptedMerge(id: string, idempotency: SentDispatchReceipt, prior: SentDispatchState): Promise<void> {
2796
+ // Old receipts predate recoverable delivery bytes and are already accepted history. Settled receipts are
2797
+ // likewise response-only: replay must not reopen or otherwise mutate a session after its later lifecycle.
2798
+ if (!prior.delivery || prior.delivered) return
2799
+ await drainSession(id)
2800
+ const current = sentDispatchReceipt(id, idempotency.operation, idempotency.requestDigest)
2801
+ if (current?.delivered) return
2802
+ await resumeSession(id, { guard: false })
2803
+ await drainSession(id)
2804
+ }
2805
+
2806
+ async function mergeSessionUnlocked(id: string, options: MergeSessionOptions = {}): Promise<MergeSessionResult> {
2807
+ const requestKey = normalizeMergeKey(options.requestKey)
2808
+ if (!requestKey) {
2809
+ return { dispatched: false, reason: 'Idempotency-Key must be 1-128 visible ASCII characters', code: 'session_merge_invalid_request', status: 400 }
2810
+ }
2811
+ const expectedBranchHead = options.expectedBranchHead, expectedBaseHead = options.expectedBaseHead
2812
+ if (expectedBranchHead === undefined || expectedBaseHead === undefined) {
2813
+ return { dispatched: false, reason: 'expectedBranchHead and expectedBaseHead are required with Idempotency-Key', code: 'session_merge_invalid_request', status: 400 }
2814
+ }
2815
+ const main = mainRoot()
2816
+ if (!isGitObjectId(main, expectedBranchHead) || !isGitObjectId(main, expectedBaseHead)) {
2817
+ return { dispatched: false, reason: 'expectedBranchHead and expectedBaseHead must be full lowercase native Git object ids', code: 'session_merge_invalid_request', status: 400 }
2818
+ }
2819
+ const idempotency = {
2820
+ operation: 'merge' as const,
2821
+ requestDigest: digest(`spexcode-session-merge\0${requestKey}`),
2822
+ payloadHash: digest(JSON.stringify({ expectedBranchHead, expectedBaseHead })),
2823
+ }
2824
+ const prior = await acceptedMergeDispatch(id, idempotency)
2825
+ if (prior) {
2826
+ if (prior.payloadHash !== idempotency.payloadHash) {
2827
+ return { dispatched: false, reason: 'Idempotency-Key is already bound to another session-merge payload', code: 'session_merge_key_reused', status: 409 }
2828
+ }
2829
+ await replayAcceptedMerge(id, idempotency, prior)
2830
+ return { dispatched: true, replayed: true, expectedBranchHead, expectedBaseHead }
2831
+ }
2832
+ const [branchObject, baseObject] = await Promise.all([
2833
+ gitTry(['-C', main, 'cat-file', '-e', `${expectedBranchHead}^{commit}`]),
2834
+ gitTry(['-C', main, 'cat-file', '-e', `${expectedBaseHead}^{commit}`]),
2835
+ ])
2836
+ if (!branchObject.ok || !baseObject.ok) {
2837
+ return {
2838
+ dispatched: false,
2839
+ reason: `reviewed heads are missing or are not commits: branch ${expectedBranchHead} / base ${expectedBaseHead}`,
2840
+ code: 'session_merge_head_changed',
2841
+ status: 409,
2842
+ }
2843
+ }
2500
2844
  const wt = await findWorktree(id)
2501
2845
  if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
2502
- const branch = wt.branch, main = mainRoot()
2503
- // ensure-live, NOT the guarded human relaunch: an already-online agent is reused (the merge prompt just needs
2504
- // a live socket), and only a confirmed-offline one is relaunched so merge never refuses on a live agent.
2505
- const re = await resumeSession(id, { guard: false })
2506
- if (!re.ok) return { dispatched: false, reason: re.error || 'could not resume session' }
2507
- const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
2846
+ const branch = wt.branch, base = mainBranch()
2847
+ // Raw bytes, not a decoded-then-re-encoded string: the gate's expected hex must be what the filesystem
2848
+ // holds, so no normalization can slip between what we promise and what git will print.
2849
+ let worktreeTop = Buffer.from(wt.path, 'utf8')
2850
+ try { worktreeTop = realpathSync(wt.path, { encoding: 'buffer' }) } catch { /* the locked proof reports the vanished worktree */ }
2851
+ const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', expectedBranchHead])).trim()
2508
2852
  const reason = subject.replace(/^spec:\s+/, '') || branch
2509
- const r = await sendText(id, mergePrompt(main, branch, reason))
2510
- if (!r.ok) return { dispatched: false, reason: r.error }
2511
- return { dispatched: true }
2853
+ const r = await sendText(id, mergePrompt(main, wt.path, worktreeTop, branch, base, expectedBranchHead, expectedBaseHead, reason), undefined, {
2854
+ idempotency,
2855
+ deferDrain: true,
2856
+ acceptGuard: async (rec) => {
2857
+ const conflict = (message: string, code: NonNullable<Extract<MergeSessionResult, { dispatched: false }>['code']>) =>
2858
+ Object.assign(new ResourceConflict(message), { code })
2859
+ if (!rec.governed || rec.status !== 'awaiting' || rec.proposal !== 'merge') {
2860
+ throw conflict(`session ${id} is not a governed awaiting merge proposal`, 'session_merge_not_proposed')
2861
+ }
2862
+ if (rec.worktreePath !== wt.path || rec.branch !== branch) {
2863
+ throw conflict(`session branch identity changed before merge acceptance`, 'session_merge_branch_unproven')
2864
+ }
2865
+ const proof = await proveMergeBranchIdentity(rec.worktreePath, branch, base)
2866
+ if (!proof.ok) throw conflict(`session branch identity is unproven: ${proof.reason}`, 'session_merge_branch_unproven')
2867
+ if (proof.branchHead !== expectedBranchHead || proof.baseHead !== expectedBaseHead) {
2868
+ throw conflict(`reviewed heads changed: expected branch ${expectedBranchHead} / base ${expectedBaseHead}, found branch ${proof.branchHead} / base ${proof.baseHead}`, 'session_merge_head_changed')
2869
+ }
2870
+ },
2871
+ })
2872
+ if (r.code === 'dispatch_key_reused') {
2873
+ return { dispatched: false, reason: 'Idempotency-Key is already bound to another session-merge payload', code: 'session_merge_key_reused', status: 409 }
2874
+ }
2875
+ if (r.code === 'session_merge_not_proposed' || r.code === 'session_merge_branch_unproven' || r.code === 'session_merge_head_changed') {
2876
+ return { dispatched: false, reason: r.error || 'merge authority refused', code: r.code, status: 409 }
2877
+ }
2878
+ if (!r.ok) return { dispatched: false, reason: r.error || 'could not dispatch merge prompt' }
2879
+ // Acceptance is already durable. Ensure-live follows it so no relaunch/state mutation can happen before the
2880
+ // reviewed CAS; a failed relaunch leaves the one queued debt for the ordinary delivery supervisor.
2881
+ await resumeSession(id, { guard: false })
2882
+ await drainSession(id)
2883
+ return { dispatched: true, replayed: r.replayed === true, expectedBranchHead, expectedBaseHead }
2512
2884
  }
2513
- export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
2514
- mergeSessionUnlocked(id)
2885
+ export const mergeSession = (id: string, options: MergeSessionOptions = {}): Promise<MergeSessionResult> =>
2886
+ mergeSessionUnlocked(id, options)
2515
2887
 
2516
2888
  // @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
2517
2889
  // process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
@@ -2525,6 +2897,12 @@ export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?
2525
2897
  // adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
2526
2898
  const AGENT_EXIT_GRACE_MS = 3000
2527
2899
  type LeafIdentity = { pid: number; startToken: string; ownerNeedle: string }
2900
+ type LeafIdentityObservation =
2901
+ | { state: 'missing' }
2902
+ | { state: 'dead'; pid: number }
2903
+ | { state: 'owned'; identity: LeafIdentity }
2904
+ | { state: 'unrelated'; pid: number; startToken: string }
2905
+ | { state: 'unknown'; pid?: number; reason: string }
2528
2906
  async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, leaf: LeafIdentity): Promise<void> {
2529
2907
  const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2530
2908
  if (pid !== leaf.pid)
@@ -2579,28 +2957,48 @@ const leafAlive = (pid: number): boolean => {
2579
2957
  catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
2580
2958
  }
2581
2959
 
2960
+ // One identity seam serves both signal teardown and cold retirement. A PID is only a locator: a live process
2961
+ // becomes target-owned when its immutable start identity is readable AND its argv carries the harness-owned
2962
+ // native identity. A live PID with a proven different argv is a stale artifact, while malformed/unreadable
2963
+ // evidence remains unknown and therefore blocks every destructive path.
2964
+ async function inspectSessionLeafIdentity(id: string, rec: SessRec): Promise<LeafIdentityObservation> {
2965
+ if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter') return { state: 'missing' }
2966
+ const path = sessionArtifactPath(id, 'agent.pid')
2967
+ let raw: string
2968
+ try { raw = readFileSync(path, 'utf8') }
2969
+ catch (error) {
2970
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return { state: 'missing' }
2971
+ return { state: 'unknown', reason: `leaf PID artifact is unreadable (${error instanceof Error ? error.message : String(error)})` }
2972
+ }
2973
+ const pid = Number(raw.trim())
2974
+ if (!Number.isSafeInteger(pid) || pid <= 0) return { state: 'unknown', reason: 'leaf PID artifact is malformed' }
2975
+ const startToken = processStartToken(pid)
2976
+ if (!startToken) return leafAlive(pid) ? { state: 'unknown', pid, reason: `leaf PID ${pid} is alive but its process-start identity is unreadable` } : { state: 'dead', pid }
2977
+ const ownerNeedle = harnessById(rec.harness || defaultHarness.id).leafOwnerNeedle?.(rec)
2978
+ if (!ownerNeedle) return { state: 'unknown', pid, reason: `no exact harness identity is registered for leaf PID ${pid}` }
2979
+ let argv: string
2980
+ try { argv = (await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' })).stdout.trim() }
2981
+ catch { return { state: 'unknown', pid, reason: `leaf PID ${pid} argv identity is unreadable` } }
2982
+ if (!argv) return { state: 'unknown', pid, reason: `leaf PID ${pid} argv identity is empty` }
2983
+ const endToken = processStartToken(pid)
2984
+ if (!endToken || endToken !== startToken)
2985
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} process-start identity changed during ownership read` }
2986
+ if (argv.includes(ownerNeedle)) return { state: 'owned', identity: { pid, startToken, ownerNeedle } }
2987
+ return { state: 'unrelated', pid, startToken }
2988
+ }
2989
+
2582
2990
  async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
2583
- const harness = harnessById(rec.harness || defaultHarness.id)
2584
- if (harness.runtimeOwnership === 'adapter') return null
2585
- // Prove the exact per-session leaf before the first tmux signal. Missing identity is acceptable only for a
2586
- // record-only/queued runtime; a live leaf without a matching pid/start/argv stays visible.
2587
- const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2588
- if (!Number.isFinite(pid) || pid <= 0) {
2991
+ if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter') return null
2992
+ const observed = await inspectSessionLeafIdentity(id, rec)
2993
+ if (observed.state === 'missing') {
2589
2994
  if (rec.stopped || rec.status === 'queued') return null
2590
2995
  throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`)
2591
2996
  }
2592
- const startToken = processStartToken(pid)
2593
- if (!startToken) {
2594
- if (rec.stopped || !leafAlive(pid)) return null
2595
- throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is alive but will not prove its start identity`)
2596
- }
2597
- const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
2598
- const ownerNeedle = harness.leafOwnerNeedle?.(rec)
2599
- if (!ownerNeedle)
2600
- throw new ResourceConflict(`refusing to stop ${id}: no exact harness identity is registered for leaf PID ${pid}`)
2601
- if (!argv.includes(ownerNeedle) || processStartToken(pid) !== startToken)
2602
- throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} does not prove argv ownership`)
2603
- return { pid, startToken, ownerNeedle }
2997
+ if (observed.state === 'dead') return null
2998
+ if (observed.state === 'owned') return observed.identity
2999
+ if (observed.state === 'unrelated')
3000
+ throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${observed.pid}@${observed.startToken} does not prove argv ownership`)
3001
+ throw new ResourceConflict(`refusing to stop ${id}: ${observed.reason}`)
2604
3002
  }
2605
3003
 
2606
3004
  async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false, coldReceipt?: unknown): Promise<void> {
@@ -2665,7 +3063,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2665
3063
  // A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
2666
3064
  // adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
2667
3065
  if (wt.rec.archived && hasValidColdProof(wt.rec)) {
2668
- const proofSnap = await liveSnapshot()
3066
+ const proofSnap = await liveSnapshot(id)
2669
3067
  if (proofSnap.probeFailed) throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`)
2670
3068
  const proofLv = h.runtimeOwnership === 'adapter'
2671
3069
  ? (proofSnap.windows.has(id) ? 'online' : 'offline')
@@ -2702,7 +3100,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2702
3100
  if (!wt) return false
2703
3101
  }
2704
3102
 
2705
- const snap = await liveSnapshot()
3103
+ const snap = await liveSnapshot(id)
2706
3104
  if (snap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`)
2707
3105
  const lv = h.runtimeOwnership === 'adapter'
2708
3106
  ? 'offline'
@@ -2711,6 +3109,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2711
3109
  throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
2712
3110
  // The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
2713
3111
  // ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
3112
+ assertSessionOwnerSafe(id, h.id)
2714
3113
  const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
2715
3114
  if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
2716
3115
  // Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
@@ -2725,7 +3124,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2725
3124
  coldCommitted = true
2726
3125
  const latest = readRecord(id)
2727
3126
  if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
2728
- const finalSnap = await liveSnapshot()
3127
+ const finalSnap = await liveSnapshot(id)
2729
3128
  if (finalSnap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: final liveness probe failed; the leaf may still be live`)
2730
3129
  const finalLv = h.runtimeOwnership === 'adapter'
2731
3130
  ? (finalSnap.windows.has(id) ? 'online' : 'offline')
@@ -2774,14 +3173,16 @@ async function assertColdRetirementSafe(id: string, rec: SessRec): Promise<void>
2774
3173
  if (rec.adapterRecovery)
2775
3174
  throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`)
2776
3175
 
2777
- const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
3176
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
2778
3177
  if (snap.probeFailed) throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`)
2779
3178
  if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`)
2780
3179
  if (socket === 'live') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`)
2781
3180
  if (socket === 'unproven') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`)
2782
- const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2783
- if (Number.isFinite(pid) && pid > 0 && processStartToken(pid))
2784
- throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${pid} is live or recycled; ownership is ambiguous`)
3181
+ const leaf = await inspectSessionLeafIdentity(id, rec)
3182
+ if (leaf.state === 'owned')
3183
+ throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${leaf.identity.pid} is live or recycled; ownership is ambiguous`)
3184
+ if (leaf.state === 'unknown')
3185
+ throw new ResourceConflict(`refusing to close archived session ${id}: ${leaf.reason}; ownership is ambiguous`)
2785
3186
 
2786
3187
  const harness = harnessById(rec.harness || defaultHarness.id)
2787
3188
  if (harness.coldRetirementPreflight) {
@@ -2818,7 +3219,7 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
2818
3219
  if (rec.adapterRecovery || launching.has(id))
2819
3220
  throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`)
2820
3221
 
2821
- const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
3222
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
2822
3223
  if (snap.probeFailed) throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`)
2823
3224
  if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`)
2824
3225
  if (socket === 'live') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`)
@@ -2850,15 +3251,21 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
2850
3251
  }
2851
3252
  }
2852
3253
 
2853
- async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
3254
+ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }, source: CloseSource): Promise<boolean> {
2854
3255
  const root = mainRoot()
2855
3256
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
2856
3257
  if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
2857
3258
  const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId
3259
+ const retired = !wt.rec.archived && !!retirementReason(wt.rec)
3260
+ // A retired row has already lost its worktree; close is its explicit record-only terminal cleanup.
3261
+ if (!retired) {
3262
+ if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
3263
+ else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
3264
+ else throw new ResourceConflict(`refusing to close ${id}: target runtime was not cold-retired first`)
3265
+ }
3266
+ // The marker protects only the destructive half. A failed cold proof must leave a normal, resumable binding.
2858
3267
  if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
2859
- if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
2860
- else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
2861
- else await stopAgentProcess(id, wt.rec)
3268
+ appendCloseLedger(id, wt.rec, source)
2862
3269
  let slot: string | null = null
2863
3270
  try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
2864
3271
  // a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
@@ -2883,13 +3290,16 @@ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch:
2883
3290
  try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
2884
3291
  catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
2885
3292
  if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
3293
+ // The close still owns this sender's record lock. Marking after its store is gone lets any send already
3294
+ // admitted finish before close returns, while every later send and every retry sweep sees terminal output.
3295
+ revokeSenderDelivery(id)
2886
3296
  if (closesCodexBinding && wt.rec.harnessSessionId) {
2887
3297
  bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
2888
3298
  }
2889
3299
  requestQueueDrain() // a close frees a slot — start the next queued session if any
2890
3300
  return true
2891
3301
  }
2892
- async function closeSessionUnlocked(id: string): Promise<boolean> {
3302
+ async function closeSessionUnlocked(id: string, source: CloseSource): Promise<boolean> {
2893
3303
  let wt: { path: string; branch: string | null; rec: SessRec } | null = null
2894
3304
  try { wt = await findWorktree(id) }
2895
3305
  catch (e) {
@@ -2906,13 +3316,30 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
2906
3316
  `refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`)
2907
3317
  }
2908
3318
  if (!wt) return false
3319
+ if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
3320
+ // A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
3321
+ // remains non-destructive while a turn is active; close already means discard this session's work.
3322
+ const harness = harnessById(wt.rec.harness || defaultHarness.id)
3323
+ assertSessionOwnerSafe(id, harness.id)
3324
+ const interrupt = harness.interrupt
3325
+ if (interrupt) {
3326
+ const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() })
3327
+ if (!result.ok) throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`)
3328
+ }
3329
+ const archived = await archiveSessionUnlocked(id)
3330
+ if (!archived) return false
3331
+ wt = await findWorktree(id)
3332
+ if (!wt) return false
3333
+ }
2909
3334
  const target = wt
2910
3335
  return target.branch
2911
- ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target))
2912
- : closeOwnedSessionUnlocked(id, target)
3336
+ ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source))
3337
+ : closeOwnedSessionUnlocked(id, target, source)
3338
+ }
3339
+ export const closeSession = (id: string, rawSource?: unknown): Promise<boolean> => {
3340
+ const source = normalizeCloseSource(rawSource)
3341
+ return withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id, source)))
2913
3342
  }
2914
- export const closeSession = (id: string): Promise<boolean> =>
2915
- withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
2916
3343
 
2917
3344
  export type CorruptRecordQuarantineWitness = {
2918
3345
  adapter: string
@@ -3253,21 +3680,21 @@ export function statusLegend(color = true): string {
3253
3680
  return c('90', ' key: ') + parts.join(' ')
3254
3681
  }
3255
3682
 
3256
- // human-friendly aligned table: header + (glyph + colour + status + name + id + merges + note) rows +
3683
+ // human-friendly aligned table: header + (glyph + colour + status + title + id + merges + note) rows +
3257
3684
  // a status legend, so the table tells the whole story (incl. each agent's note) at a glance.
3258
3685
  export function formatTable(sessions: Session[], color = true): string {
3259
3686
  const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
3260
3687
  if (!sessions.length) return c('90', ' no living sessions')
3261
- const header = c('90', ` ${'STATUS'.padEnd(13)} ${'NODE'.padEnd(22)} ${'ID'.padEnd(8)} ${'\u00d7'.padEnd(4)}${'PROMPT'.padEnd(42)}NOTE`)
3688
+ const header = c('90', ` ${'STATUS'.padEnd(13)} ${'TITLE'.padEnd(22)} ${'ID'.padEnd(8)} ${'\u00d7'.padEnd(4)}${'PROMPT'.padEnd(42)}NOTE`)
3262
3689
  const rows = sessions.map((s) => {
3263
3690
  const g = STATUS_GLYPH[s.status] ?? '\u00b7'
3264
3691
  const code = ANSI[s.status] ?? '0'
3265
- const name = padWidth(truncWidth(sessionLabel(s), 22), 22)
3692
+ const title = padWidth(truncWidth(sessionTitle(s), 22), 22)
3266
3693
  const st = s.status.padEnd(13)
3267
3694
  const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
3268
3695
  const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)) // what it was asked to do
3269
3696
  const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
3270
- return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
3697
+ return ` ${c(code, g)} ${c(code, st)} ${title} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
3271
3698
  })
3272
3699
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
3273
3700
  }
@@ -3280,27 +3707,72 @@ export function formatTable(sessions: Session[], color = true): string {
3280
3707
  // A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
3281
3708
  // that cannot reach an agent must at least leave a trace ([[session-timeline]]).
3282
3709
  // (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
3283
- export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
3710
+ type DispatchIdempotency = SentDispatchReceipt
3711
+ type DispatchAcceptCode = 'dispatch_key_reused' | 'session_merge_not_proposed' | 'session_merge_branch_unproven' | 'session_merge_head_changed'
3712
+ type AcceptedDispatch = DispatchResult & { replayed?: boolean; code?: DispatchAcceptCode }
3713
+ type SendTextOptions = {
3714
+ replyVia?: 'note'
3715
+ idempotency?: DispatchIdempotency
3716
+ acceptGuard?: (record: SessRec) => Promise<void>
3717
+ deferDrain?: boolean
3718
+ }
3719
+ export async function sendText(id: string, text: string, from?: string, opts: SendTextOptions = {}): Promise<AcceptedDispatch> {
3284
3720
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
3285
- const rec = readRecord(id)
3286
- if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
3287
- // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
3288
- // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
3289
- // a message that was already accepted.
3290
- const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3721
+ let replayed = false
3291
3722
  try {
3292
- await withRecordLock(id, async () => {
3293
- const appended = appendSent(id, text, from ?? null, prompt.replyVia)
3294
- enqueue(id, { mid: appended.mid, text: prompt.text, from: from ?? null })
3723
+ // Taking a declared sender's record lock makes close a real outgoing fence even across backend processes:
3724
+ // a send either appends before close obtains the fence (and close's revocation voids its debt), or sees
3725
+ // the terminal marker before it records anything. Arbitrary legacy `from` values keep working; they just
3726
+ // name an otherwise-unused lock until a matching session is closed.
3727
+ await withRecordLocks([id, ...(from ? [from] : [])].sort(), async () => {
3728
+ if (from && senderDeliveryRevoked(from)) throw new ResourceConflict(`sender session ${from} is closed; prompt NOT delivered`)
3729
+ const rec = readRecord(id)
3730
+ if (!rec) throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`)
3731
+ const accept = async () => {
3732
+ if (opts.idempotency) {
3733
+ const prior = sentDispatchReceipt(id, opts.idempotency.operation, opts.idempotency.requestDigest)
3734
+ if (prior) {
3735
+ if (prior.payloadHash !== opts.idempotency.payloadHash) {
3736
+ const conflict = new ResourceConflict(`idempotency key is already bound to another ${opts.idempotency.operation} payload`)
3737
+ Object.assign(conflict, { code: 'dispatch_key_reused' })
3738
+ throw conflict
3739
+ }
3740
+ if (prior.delivery && !prior.delivered) {
3741
+ ensurePendingWhileLocked(id, keyedPendingMessage(opts.idempotency, prior.mid, prior.delivery))
3742
+ }
3743
+ replayed = true
3744
+ return
3745
+ }
3746
+ }
3747
+ await opts.acceptGuard?.(rec)
3748
+ // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
3749
+ // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
3750
+ // a message that was already accepted.
3751
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3752
+ const dispatchReceipt = opts.idempotency
3753
+ ? { ...opts.idempotency, delivery: { text: prompt.text, from: from ?? null } }
3754
+ : undefined
3755
+ const appended = appendSent(id, text, from ?? null, prompt.replyVia, dispatchReceipt)
3756
+ enqueue(id, opts.idempotency
3757
+ ? keyedPendingMessage(opts.idempotency, appended.mid, dispatchReceipt!.delivery!)
3758
+ : { mid: appended.mid, text: prompt.text, from: from ?? null })
3759
+ }
3760
+ if (opts.idempotency) await withDeliveryLocks([id], accept)
3761
+ else await accept()
3295
3762
  })
3296
3763
  } catch (error) {
3297
- return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
3764
+ const code = (error as { code?: DispatchAcceptCode })?.code
3765
+ return {
3766
+ ok: false,
3767
+ error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`,
3768
+ ...(code ? { code } : {}),
3769
+ }
3298
3770
  }
3299
3771
  // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
3300
3772
  // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
3301
3773
  // the text in a live agent's current turn instead of up to one tick later.
3302
- await drainSession(id)
3303
- return { ok: true }
3774
+ if (!opts.deferDrain) await drainSession(id)
3775
+ return { ok: true, ...(opts.idempotency ? { replayed } : {}) }
3304
3776
  }
3305
3777
 
3306
3778
  // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
@@ -3312,6 +3784,12 @@ export async function drainSession(id: string): Promise<void> {
3312
3784
  if (!rec) return
3313
3785
  const h = harnessById(rec.harness || defaultHarness.id)
3314
3786
  await drain(id, async (msg) => {
3787
+ if (msg.dispatch) {
3788
+ const receipt = sentDispatchReceipt(id, msg.dispatch.operation, msg.dispatch.requestDigest)
3789
+ if (!receipt || receipt.mid !== msg.mid || !receipt.delivery
3790
+ || receipt.delivery.text !== msg.text || receipt.delivery.from !== msg.from) return false
3791
+ if (receipt.delivered) return true
3792
+ }
3315
3793
  // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3316
3794
  // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
3317
3795
  // rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
@@ -3320,7 +3798,9 @@ export async function drainSession(id: string): Promise<void> {
3320
3798
  if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
3321
3799
  } catch { /* no pane to consult — let the insert itself decide */ }
3322
3800
  }
3323
- return (await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)).ok
3801
+ const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)
3802
+ if (delivered.ok && msg.dispatch) settleSentDispatch(id, msg.mid)
3803
+ return delivered.ok
3324
3804
  })
3325
3805
  }
3326
3806