spexcode 0.6.0 → 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 (88) 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 +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -6,21 +6,24 @@ 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
 
22
24
  const pexec = promisify(execFile)
23
25
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
26
+ const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' }
24
27
  const HARNESS = defaultHarness
25
28
  const COLS = 120, ROWS = 32
26
29
  const DEFAULT_MAX_ACTIVE = 8
@@ -72,7 +75,7 @@ const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', noth
72
75
 
73
76
  export type Session = {
74
77
  id: string; node: string | null; branch: string | null; path: string
75
- label: string; headline: string // the DERIVED display strings ([[session-label]]) the only names surfaces read
78
+ label: string; title: string // `label` remains the stable search handle; `title` is the one visible session name
76
79
  raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
77
80
  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
78
81
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
@@ -83,6 +86,40 @@ export type Session = {
83
86
  archiveHazard?: string | null // explicit legacy/invariant violation; never hidden as a clean archive
84
87
  prompt: string | null; promptPreview: string | null; created: number; activity: string | null
85
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`)
86
123
  }
87
124
 
88
125
  function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
@@ -111,18 +148,26 @@ function removeLaunchFile(id: string): void {
111
148
 
112
149
  // One line, bounded — the launch prompt's shape when it enters a compact headline.
113
150
  export const HEADLINE_PREVIEW_COLUMNS = 60
151
+ function isBareUrl(text: string): boolean {
152
+ return /^(?:https?|git|ssh):\/\/\S+$/i.test(text)
153
+ }
114
154
  function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
115
- const first = text.split('\n').map((l) => l.trim()).find(Boolean) || ''
155
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
156
+ const first = lines.find((line) => !isBareUrl(line)) || lines[0] || ''
116
157
  return first.length > n ? first.slice(0, n - 1) + '…' : first
117
158
  }
118
159
 
119
160
  export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
120
161
  r.name || r.node || r.title || r.branch || r.id
121
- export const deriveHeadline = (r: { name?: string | null; activity?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
122
- r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
162
+ export const deriveTitle = (r: { name?: string | null; activity?: string | null; note?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
163
+ r.name || r.activity || (r.note ? oneLinePreview(r.note) : '') || (r.promptPreview ? oneLinePreview(r.promptPreview) : '') || r.node || r.title || r.branch || r.id
164
+ // Compatibility for package consumers that still import the old name.
165
+ export const deriveHeadline = deriveTitle
123
166
 
124
167
  export const sessionLabel = (s: Session): string => s.label
125
- export const sessionHeadline = (s: Session): string => s.headline
168
+ export const sessionTitle = (s: Session): string => s.title
169
+ // Compatibility for older callers; all visible surfaces now resolve through `title`.
170
+ export const sessionHeadline = sessionTitle
126
171
 
127
172
  // @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
128
173
  // HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
@@ -130,6 +175,9 @@ export const sessionHeadline = (s: Session): string => s.headline
130
175
  // execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
131
176
  // "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
132
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
133
181
  async function tmux(args: string[], timeoutMs?: number): Promise<string> {
134
182
  const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' as const } : {}) })
135
183
  return stdout
@@ -163,6 +211,7 @@ export type SessRec = {
163
211
  launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
164
212
  createRequestId?: string | null // digest of the public Idempotency-Key; binds retry without storing the bearer
165
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
166
215
  launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
167
216
  }
168
217
  type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
@@ -290,10 +339,11 @@ async function withRecordLock<T>(id: string, body: () => Promise<T>, signal?: Ab
290
339
  const release = await acquireRecordLock(id, 30_000, signal)
291
340
  try { return await body() } finally { release() }
292
341
  }
293
- function withRecordLockSync<T>(id: string, body: () => T): T {
342
+ export function withSessionRecordLockSync<T>(id: string, body: () => T): T {
294
343
  const release = acquireRecordLockSync(id)
295
344
  try { return body() } finally { release() }
296
345
  }
346
+ const withRecordLockSync = withSessionRecordLockSync
297
347
  function tryRecordLockSync(id: string): (() => void) | null {
298
348
  mkdirSync(recordLockRoot(), { recursive: true })
299
349
  const path = recordLockPath(id)
@@ -357,6 +407,7 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
357
407
  launchOwner: launchOwner || null,
358
408
  createRequestId: raw.create_request_id || null,
359
409
  createPayloadHash: raw.create_payload_hash || null,
410
+ base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
360
411
  launchReadinessPending: pendingRaw ? {
361
412
  version: 1,
362
413
  startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
@@ -424,6 +475,9 @@ function writeRecord(rec: SessRec): void {
424
475
  launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
425
476
  create_request_id: rec.createRequestId ?? '',
426
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 } : {}),
427
481
  launch_readiness_pending: rec.launchReadinessPending ? {
428
482
  version: 1,
429
483
  startedAt: rec.launchReadinessPending.startedAt,
@@ -449,25 +503,216 @@ function writeRecord(rec: SessRec): void {
449
503
  if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
450
504
  || previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
451
505
  recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note)
506
+ scheduleWatchNotifications(rec)
452
507
  }
453
508
  }
454
509
 
510
+ type WatchEntry = { watcher: string; createdAt: string }
511
+ export type SessionWatch = { target: string; createdAt: string }
512
+ const watchPath = (target: string) => sessionArtifactPath(target, 'watchers.json')
513
+
514
+ function readWatchEntries(target: string): WatchEntry[] {
515
+ try {
516
+ const raw = JSON.parse(readFileSync(watchPath(target), 'utf8')) as unknown
517
+ if (!Array.isArray(raw)) return []
518
+ const seen = new Set<string>()
519
+ return raw.flatMap((entry): WatchEntry[] => {
520
+ if (!entry || typeof entry !== 'object') return []
521
+ const watcher = (entry as WatchEntry).watcher
522
+ const createdAt = (entry as WatchEntry).createdAt
523
+ if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher)) return []
524
+ seen.add(watcher)
525
+ return [{ watcher, createdAt }]
526
+ })
527
+ } catch { return [] }
528
+ }
529
+
530
+ function writeWatchEntries(target: string, entries: WatchEntry[]): void {
531
+ const path = watchPath(target)
532
+ if (!entries.length) { try { unlinkSync(path) } catch { /* already absent */ }; return }
533
+ const dir = sessionStoreDir(target)
534
+ mkdirSync(dir, { recursive: true })
535
+ const tmp = join(dir, `.watchers.json.${process.pid}.tmp`)
536
+ writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n')
537
+ renameSync(tmp, path)
538
+ }
539
+
540
+ function managedWatchRecord(id: string): SessRec {
541
+ const rec = readRecord(id)
542
+ if (!rec?.governed) throw new ResourceConflict(`session ${id} is not a governed session and cannot participate in a durable watch`)
543
+ return rec
544
+ }
545
+
546
+ function watchMessage(target: SessRec): string {
547
+ const status = target.status === 'awaiting'
548
+ ? PROPOSAL_STATUS[target.proposal ?? 'nothing']
549
+ : target.status === 'active' ? 'working' : target.status
550
+ const note = target.note ? ` — ${target.note}` : ''
551
+ return `[spex watch] ${target.session} is ${status}${note}`
552
+ }
553
+
554
+ function scheduleWatchNotifications(target: SessRec): void {
555
+ const watchers = readWatchEntries(target.session).map((entry) => entry.watcher)
556
+ if (!watchers.length) return
557
+ queueMicrotask(() => {
558
+ for (const watcher of watchers) {
559
+ void sendText(watcher, watchMessage(target), target.session).then((result) => {
560
+ if (!result.ok) console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`)
561
+ })
562
+ }
563
+ })
564
+ }
565
+
566
+ export async function subscribeSessionWatch(watcher: string, targets: string[]): Promise<{ watched: string[] }> {
567
+ managedWatchRecord(watcher)
568
+ const watched: string[] = []
569
+ for (const target of [...new Set(targets)]) {
570
+ if (target === watcher) throw new ResourceConflict('a session cannot watch itself')
571
+ const targetRecord = managedWatchRecord(target)
572
+ withRecordLockSync(target, () => {
573
+ const entries = readWatchEntries(target)
574
+ if (!entries.some((entry) => entry.watcher === watcher)) {
575
+ writeWatchEntries(target, [...entries, { watcher, createdAt: new Date().toISOString() }])
576
+ }
577
+ })
578
+ const delivered = await sendText(watcher, watchMessage(targetRecord), target)
579
+ if (!delivered.ok) throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`)
580
+ watched.push(target)
581
+ }
582
+ return { watched }
583
+ }
584
+
585
+ export function listSessionWatches(watcher: string): SessionWatch[] {
586
+ managedWatchRecord(watcher)
587
+ const watches: SessionWatch[] = []
588
+ for (const target of listSessionIds()) {
589
+ const entries = readWatchEntries(target)
590
+ const active = entries.filter((entry) => {
591
+ try { return !!readRecord(entry.watcher)?.governed } catch { return false }
592
+ })
593
+ if (active.length !== entries.length) writeWatchEntries(target, active)
594
+ for (const entry of active) if (entry.watcher === watcher) watches.push({ target, createdAt: entry.createdAt })
595
+ }
596
+ return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target))
597
+ }
598
+
599
+ export function cancelSessionWatch(watcher: string, targets: string[]): number {
600
+ managedWatchRecord(watcher)
601
+ let cancelled = 0
602
+ for (const target of [...new Set(targets)]) {
603
+ withRecordLockSync(target, () => {
604
+ const entries = readWatchEntries(target)
605
+ const kept = entries.filter((entry) => entry.watcher !== watcher)
606
+ if (kept.length !== entries.length) {
607
+ writeWatchEntries(target, kept)
608
+ cancelled++
609
+ }
610
+ })
611
+ }
612
+ return cancelled
613
+ }
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
+
455
691
  // Share one liveness snapshot rather than spawning tmux for every displayed session.
456
692
  export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
457
693
 
458
- // 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.
459
702
  export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
460
703
  const m = new Map<string, { panePid?: number; title?: string }>()
461
704
  for (const line of out.split('\n')) {
462
705
  if (!line) continue
463
- 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)
464
709
  const name = (t1 < 0 ? line : line.slice(0, t1)).trim()
465
710
  if (!name || m.has(name)) continue // first pane per session wins
466
711
  if (t1 < 0) { m.set(name, {}); continue }
467
- const rest = line.slice(t1 + 1)
468
- const t2 = rest.indexOf('\t')
712
+ const rest = line.slice(t1 + separator.length)
713
+ const t2 = rest.indexOf(separator)
469
714
  const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim())
470
- const title = t2 < 0 ? '' : rest.slice(t2 + 1)
715
+ const title = t2 < 0 ? '' : rest.slice(t2 + separator.length)
471
716
  m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined })
472
717
  }
473
718
  return m
@@ -498,13 +743,17 @@ export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean
498
743
  return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
499
744
  }
500
745
 
501
- async function liveSnapshot(): Promise<LiveSnap> {
746
+ async function liveSnapshot(targetId?: string): Promise<LiveSnap> {
502
747
  const windows = new Map<string, PaneProbe>()
503
748
  const titles = new Map<string, string>()
504
749
  let out: string
505
750
  try {
506
751
  // ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
507
- 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)
508
757
  } catch (e) {
509
758
  // a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
510
759
  // non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
@@ -532,7 +781,12 @@ async function liveSnapshot(): Promise<LiveSnap> {
532
781
  // The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
533
782
  // in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
534
783
  const ids = [...windows.keys()]
535
- 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
+ }
536
790
  const sockets = new Set<string>()
537
791
  const unproven = new Set<string>()
538
792
  ids.forEach((id, i) => {
@@ -663,14 +917,30 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
663
917
  return { path: rec.worktreePath, branch: rec.branch, rec }
664
918
  }
665
919
 
920
+ // @@@ identity WITHOUT the gates - reviewPayload answers two different questions at once: who is this
921
+ // session (a store read, free) and how does its branch stand against main (ahead count, dirty scan, a
922
+ // merge-tree conflict probe — 646 ms and 8 git children on a far-diverged branch). A consumer that renders
923
+ // no gates strip should not buy the second one. The record already holds the identity half.
924
+ export type ReviewIdentity = { id: string; node: string | null; branch: string | null; label: string }
925
+ export function reviewIdentity(id: string): ReviewIdentity | null {
926
+ const rec = readRecord(id)
927
+ if (!rec) return null
928
+ return {
929
+ id,
930
+ node: rec.node,
931
+ branch: rec.branch,
932
+ label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
933
+ }
934
+ }
935
+
666
936
  function corruptSession(id: string, entry: { path: string; error: string }): Session {
667
937
  const label = `${id.slice(0, 8)} (unreadable record)`
668
938
  return {
669
- id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
939
+ id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
670
940
  parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
671
941
  lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
672
942
  note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
673
- activity: null, sortKey: null, archiveHazard: null,
943
+ activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
674
944
  }
675
945
  }
676
946
 
@@ -681,9 +951,9 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
681
951
  const showActivity = lv === 'online'
682
952
  const act = showActivity ? activity : null
683
953
  const pp = prompt ? oneLinePreview(prompt) : null
684
- const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
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 }
685
955
  const harness = harnessById(rec.harness || defaultHarness.id)
686
- return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(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) }
687
957
  }
688
958
 
689
959
  export async function renameSession(id: string, name: string): Promise<boolean> {
@@ -715,6 +985,14 @@ export async function sessionPrompt(id: string): Promise<string | null> {
715
985
  // Preserve rows through a transient record-read failure; prune after the store entry disappears.
716
986
  const lastKnownSession = new Map<string, Session>()
717
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
+
718
996
  export async function listSessions(includeArchived = false): Promise<Session[]> {
719
997
  // ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
720
998
  // every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
@@ -762,7 +1040,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
762
1040
  // A forced public liveness comes only from the shared record projection. Do not let live process/thread
763
1041
  // evidence punch through it (including archive hazard repair).
764
1042
  if (entry.kind === 'ok' && entry.liveness === 'offline') {
765
- const pending = toSession(rec, 'offline', 'offline')
1043
+ const pending = boardRow(toSession(rec, 'offline', 'offline'))
766
1044
  lastKnownSession.set(id, pending)
767
1045
  return pending
768
1046
  }
@@ -786,7 +1064,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
786
1064
  const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true)
787
1065
  const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec
788
1066
  const projectedLv = projected === rec ? liveness(rec, snap) : physical!
789
- const s = toSession(projected, reconcile(projected, snap), projectedLv, activity)
1067
+ const s = boardRow(toSession(projected, reconcile(projected, snap), projectedLv, activity))
790
1068
  if (projected !== rec) s.archiveHazard = changedDuringCensus.has(id)
791
1069
  ? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
792
1070
  : hasValidColdProof(rec)
@@ -825,10 +1103,16 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
825
1103
  export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
826
1104
  export type ApiBaseInfo = { url: string; source: ApiBaseSource }
827
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
+
828
1112
  // the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
829
1113
  // `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
830
1114
  function explicitApiFlag(): string | null {
831
- const argv = process.argv
1115
+ const argv = optionArgv()
832
1116
  const ai = argv.indexOf('--api')
833
1117
  if (ai >= 0) {
834
1118
  const v = argv[ai + 1]
@@ -1405,7 +1689,7 @@ export class SessionCreateError extends Error {
1405
1689
  this.name = 'SessionCreateError'
1406
1690
  }
1407
1691
  }
1408
- type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal }
1692
+ type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal; base?: string | null }
1409
1693
  type SessionCreateRequestOptions = {
1410
1694
  requestKey?: string
1411
1695
  signal?: AbortSignal
@@ -1454,12 +1738,16 @@ function throwIfCreateAborted(signal: AbortSignal, phase: SessionCreatePhase): v
1454
1738
  export async function sessionCreateRequest(body: unknown, options: SessionCreateRequestOptions = {}): Promise<SessionCreateRequestResult> {
1455
1739
  if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
1456
1740
  const input = body as Record<string, unknown>
1457
- 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()
1458
1742
  if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
1459
1743
  const prompt = typeof input.prompt === 'string' ? input.prompt : ''
1460
1744
  if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
1461
1745
  const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
1462
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
1463
1751
  let key: string
1464
1752
  try { key = normalizeCreateKey(options.requestKey) }
1465
1753
  catch (error) {
@@ -1468,7 +1756,10 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
1468
1756
  }
1469
1757
  const requestDigest = digest(key)
1470
1758
  const id = sessionIdForCreateKey(key)
1471
- 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 } : {}) }))
1472
1763
  const controller = new AbortController()
1473
1764
  const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408))
1474
1765
  if (options.signal?.aborted) cancel()
@@ -1478,7 +1769,7 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
1478
1769
  traceSessionCreate(id, requestDigest, 'request', 'start')
1479
1770
  try {
1480
1771
  try {
1481
- 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 })
1482
1773
  traceSessionCreate(id, requestDigest, 'request', 'finish')
1483
1774
  return { status: 201, session }
1484
1775
  } catch (error) {
@@ -1527,15 +1818,16 @@ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean
1527
1818
  return false
1528
1819
  } finally { clearTimeout(timer) }
1529
1820
  }
1530
- 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> {
1531
1822
  const parent = ownSessionId()
1532
1823
  const requestKey = randomUUID()
1824
+ const body = { prompt, parent, launcher, ...(name !== undefined ? { name } : {}), ...(base !== undefined ? { base } : {}) }
1533
1825
  const target = await apiBaseInfo()
1534
- const base = target.url
1826
+ const apiUrl = target.url
1535
1827
  const refused = await probeSessionCreateAuthority(target)
1536
1828
  if (refused) {
1537
1829
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
1538
- const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey })
1830
+ const fallback = await sessionCreateRequest(body, { requestKey })
1539
1831
  if (fallback.status === 201) return fallback.session
1540
1832
  const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
1541
1833
  error.name = 'BackendError'
@@ -1546,10 +1838,10 @@ export async function createSession(prompt: string, launcher?: string): Promise<
1546
1838
  timer.unref?.()
1547
1839
  let res: Response
1548
1840
  try {
1549
- res = await fetch(`${base}/api/sessions`, {
1841
+ res = await fetch(`${apiUrl}/api/sessions`, {
1550
1842
  method: 'POST',
1551
1843
  headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
1552
- body: JSON.stringify({ prompt, parent, launcher }),
1844
+ body: JSON.stringify(body),
1553
1845
  signal: controller.signal,
1554
1846
  })
1555
1847
  } catch (error) {
@@ -1669,6 +1961,23 @@ type SessionCandidateReceiptRead =
1669
1961
  const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
1670
1962
  const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
1671
1963
  const sessionCandidateLockId = (path: string, branch: string) => `create-resource-${digest(`${path}\0${branch}`)}`
1964
+ // The graph watcher uses this private fence to avoid rebuilding the full board while Git is still
1965
+ // registering a session candidate. The receipt is written before `git worktree add` and retired only
1966
+ // after publication or bounded cleanup, so the path names exactly the transaction-owned worktree.
1967
+ export function pendingSessionCreateWorktreePaths(): Set<string> {
1968
+ const paths = new Set<string>()
1969
+ let entries: import('node:fs').Dirent[]
1970
+ try { entries = readdirSync(sessionCandidateReceiptDir(), { withFileTypes: true }) }
1971
+ catch { return paths }
1972
+ for (const entry of entries) {
1973
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue
1974
+ try {
1975
+ const value = JSON.parse(readFileSync(join(sessionCandidateReceiptDir(), entry.name), 'utf8')) as Partial<SessionCandidateReceipt>
1976
+ if (typeof value.path === 'string' && value.path && typeof value.stage === 'string') paths.add(resolve(value.path))
1977
+ } catch { /* an in-flight atomic replace is not a candidate path */ }
1978
+ }
1979
+ return paths
1980
+ }
1672
1981
  function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
1673
1982
  const path = sessionCandidateReceiptPath(id)
1674
1983
  if (!existsSync(path)) return { kind: 'absent' }
@@ -1796,8 +2105,8 @@ async function proveSessionCandidate(path: string, branch: string, signal: Abort
1796
2105
  return null
1797
2106
  }
1798
2107
 
1799
- async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
1800
- 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
1801
2110
  let phase: SessionCreatePhase = 'creation-lock'
1802
2111
  let shouldDrain = false
1803
2112
  traceSessionCreate(id, requestDigest, phase, 'start')
@@ -1836,6 +2145,16 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1836
2145
  const title = ref ? null : titleFromPrompt(rawPrompt)
1837
2146
  const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1838
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
+ }
1839
2158
  const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`
1840
2159
  const path = join(root, '.worktrees', slug)
1841
2160
  const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined
@@ -1857,7 +2176,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1857
2176
  const resourceLock = sessionCandidateLockId(path, branch)
1858
2177
  return await withRecordLock(resourceLock, async () => {
1859
2178
  throwIfCreateAborted(signal, phase)
2179
+ traceSessionCreate(id, requestDigest, phase, 'start', 'candidate-state')
1860
2180
  let before = await sessionCandidateState(root, path, branch, signal)
2181
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'candidate-state')
1861
2182
  let storePresent = existsSync(sessionStoreDir(id))
1862
2183
  const durable = readSessionCandidateReceipt(id)
1863
2184
  if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
@@ -1889,7 +2210,12 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1889
2210
  let published = false
1890
2211
  try {
1891
2212
  gitMutationStarted = true
1892
- const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()]))
2213
+ traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add')
2214
+ const added = await withGitAbortSignal(signal, () => gitTry(
2215
+ ['-C', root, 'worktree', 'add', '-b', branch, path, startPoint],
2216
+ { extraEnv: DEFER_FOOTPRINT_REFRESH },
2217
+ ))
2218
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add')
1893
2219
  if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
1894
2220
  if (!added.ok || !existsSync(path)) {
1895
2221
  throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
@@ -1897,14 +2223,17 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1897
2223
  candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
1898
2224
  writeSessionCandidateReceipt(id, candidateReceipt)
1899
2225
  traceSessionCreate(id, requestDigest, phase, 'finish')
2226
+ traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state')
1900
2227
  seedWorktreeHostState(root, path)
2228
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state')
1901
2229
 
1902
2230
  let rec: SessRec = {
1903
2231
  session: id, governed: true, worktreePath: path, branch,
1904
- node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
2232
+ node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
1905
2233
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
1906
2234
  harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
1907
2235
  launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
2236
+ ...(base ? { base } : {}),
1908
2237
  }
1909
2238
  owned.store = true
1910
2239
  const dir = storeDir(id)
@@ -2254,6 +2583,8 @@ export type ReviewGates = {
2254
2583
  }
2255
2584
  export type ReviewPayload = {
2256
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
2257
2588
  label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
2258
2589
  ahead: number // commits the node branch is ahead of main
2259
2590
  dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
@@ -2262,6 +2593,23 @@ export type ReviewPayload = {
2262
2593
  proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
2263
2594
  }
2264
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
+
2265
2613
  // @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
2266
2614
  // (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
2267
2615
  // it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
@@ -2308,19 +2656,28 @@ async function lintGate(): Promise<ReviewGates['lint']> {
2308
2656
  export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2309
2657
  const wt = await findWorktree(id)
2310
2658
  if (!wt) return null
2659
+ if (!wt.rec.governed || !wt.branch) throw new ResourceConflict(`session ${id} has no governed branch to review`)
2311
2660
  const base = mainBranch()
2661
+ const { branchHead, baseHead } = await reviewHeadPair(wt.path, wt.branch, base)
2312
2662
  const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
2313
- gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
2663
+ gitA(['-C', wt.path, 'rev-list', '--count', `${baseHead}..${branchHead}`]),
2314
2664
  gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
2315
- mergeBaseDiff(wt.path, base),
2316
- mergeConflicts(wt.path, base),
2665
+ mergeBaseDiff(wt.path, baseHead, branchHead),
2666
+ mergeConflicts(wt.path, baseHead, branchHead),
2317
2667
  lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
2318
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
+ }
2319
2676
  // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
2320
2677
  // path is genuine work — this is just the total uncommitted count.
2321
2678
  const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
2322
2679
  return {
2323
- id, node: wt.rec.node, branch: wt.branch,
2680
+ id, node: wt.rec.node, branch: wt.branch, branchHead, baseHead,
2324
2681
  label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
2325
2682
  ahead: Number(aheadOut.trim()) || 0,
2326
2683
  dirtyNonRuntime, diff,
@@ -2329,32 +2686,204 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2329
2686
  }
2330
2687
  }
2331
2688
 
2332
- function mergePrompt(mainPath: string, branch: string, reason: string): string {
2333
- 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'`
2334
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` +
2335
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` +
2336
- `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` +
2337
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. ` +
2338
- `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. ` +
2339
2740
  `5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
2340
2741
  }
2341
2742
 
2342
- 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
+ }
2343
2844
  const wt = await findWorktree(id)
2344
2845
  if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
2345
- const branch = wt.branch, main = mainRoot()
2346
- // ensure-live, NOT the guarded human relaunch: an already-online agent is reused (the merge prompt just needs
2347
- // a live socket), and only a confirmed-offline one is relaunched so merge never refuses on a live agent.
2348
- const re = await resumeSession(id, { guard: false })
2349
- if (!re.ok) return { dispatched: false, reason: re.error || 'could not resume session' }
2350
- 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()
2351
2852
  const reason = subject.replace(/^spec:\s+/, '') || branch
2352
- const r = await sendText(id, mergePrompt(main, branch, reason))
2353
- if (!r.ok) return { dispatched: false, reason: r.error }
2354
- 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 }
2355
2884
  }
2356
- export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
2357
- mergeSessionUnlocked(id)
2885
+ export const mergeSession = (id: string, options: MergeSessionOptions = {}): Promise<MergeSessionResult> =>
2886
+ mergeSessionUnlocked(id, options)
2358
2887
 
2359
2888
  // @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
2360
2889
  // process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
@@ -2368,6 +2897,12 @@ export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?
2368
2897
  // adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
2369
2898
  const AGENT_EXIT_GRACE_MS = 3000
2370
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 }
2371
2906
  async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, leaf: LeafIdentity): Promise<void> {
2372
2907
  const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2373
2908
  if (pid !== leaf.pid)
@@ -2422,28 +2957,48 @@ const leafAlive = (pid: number): boolean => {
2422
2957
  catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
2423
2958
  }
2424
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
+
2425
2990
  async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
2426
- const harness = harnessById(rec.harness || defaultHarness.id)
2427
- if (harness.runtimeOwnership === 'adapter') return null
2428
- // Prove the exact per-session leaf before the first tmux signal. Missing identity is acceptable only for a
2429
- // record-only/queued runtime; a live leaf without a matching pid/start/argv stays visible.
2430
- const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2431
- 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') {
2432
2994
  if (rec.stopped || rec.status === 'queued') return null
2433
2995
  throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`)
2434
2996
  }
2435
- const startToken = processStartToken(pid)
2436
- if (!startToken) {
2437
- if (rec.stopped || !leafAlive(pid)) return null
2438
- throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is alive but will not prove its start identity`)
2439
- }
2440
- const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
2441
- const ownerNeedle = harness.leafOwnerNeedle?.(rec)
2442
- if (!ownerNeedle)
2443
- throw new ResourceConflict(`refusing to stop ${id}: no exact harness identity is registered for leaf PID ${pid}`)
2444
- if (!argv.includes(ownerNeedle) || processStartToken(pid) !== startToken)
2445
- throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} does not prove argv ownership`)
2446
- 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}`)
2447
3002
  }
2448
3003
 
2449
3004
  async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false, coldReceipt?: unknown): Promise<void> {
@@ -2508,7 +3063,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2508
3063
  // A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
2509
3064
  // adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
2510
3065
  if (wt.rec.archived && hasValidColdProof(wt.rec)) {
2511
- const proofSnap = await liveSnapshot()
3066
+ const proofSnap = await liveSnapshot(id)
2512
3067
  if (proofSnap.probeFailed) throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`)
2513
3068
  const proofLv = h.runtimeOwnership === 'adapter'
2514
3069
  ? (proofSnap.windows.has(id) ? 'online' : 'offline')
@@ -2545,7 +3100,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2545
3100
  if (!wt) return false
2546
3101
  }
2547
3102
 
2548
- const snap = await liveSnapshot()
3103
+ const snap = await liveSnapshot(id)
2549
3104
  if (snap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`)
2550
3105
  const lv = h.runtimeOwnership === 'adapter'
2551
3106
  ? 'offline'
@@ -2554,6 +3109,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2554
3109
  throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
2555
3110
  // The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
2556
3111
  // ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
3112
+ assertSessionOwnerSafe(id, h.id)
2557
3113
  const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
2558
3114
  if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
2559
3115
  // Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
@@ -2568,7 +3124,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2568
3124
  coldCommitted = true
2569
3125
  const latest = readRecord(id)
2570
3126
  if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
2571
- const finalSnap = await liveSnapshot()
3127
+ const finalSnap = await liveSnapshot(id)
2572
3128
  if (finalSnap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: final liveness probe failed; the leaf may still be live`)
2573
3129
  const finalLv = h.runtimeOwnership === 'adapter'
2574
3130
  ? (finalSnap.windows.has(id) ? 'online' : 'offline')
@@ -2617,14 +3173,16 @@ async function assertColdRetirementSafe(id: string, rec: SessRec): Promise<void>
2617
3173
  if (rec.adapterRecovery)
2618
3174
  throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`)
2619
3175
 
2620
- const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
3176
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
2621
3177
  if (snap.probeFailed) throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`)
2622
3178
  if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`)
2623
3179
  if (socket === 'live') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`)
2624
3180
  if (socket === 'unproven') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`)
2625
- const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2626
- if (Number.isFinite(pid) && pid > 0 && processStartToken(pid))
2627
- 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`)
2628
3186
 
2629
3187
  const harness = harnessById(rec.harness || defaultHarness.id)
2630
3188
  if (harness.coldRetirementPreflight) {
@@ -2661,7 +3219,7 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
2661
3219
  if (rec.adapterRecovery || launching.has(id))
2662
3220
  throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`)
2663
3221
 
2664
- const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
3222
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
2665
3223
  if (snap.probeFailed) throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`)
2666
3224
  if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`)
2667
3225
  if (socket === 'live') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`)
@@ -2693,15 +3251,21 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
2693
3251
  }
2694
3252
  }
2695
3253
 
2696
- 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> {
2697
3255
  const root = mainRoot()
2698
3256
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
2699
3257
  if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
2700
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.
2701
3267
  if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
2702
- if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
2703
- else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
2704
- else await stopAgentProcess(id, wt.rec)
3268
+ appendCloseLedger(id, wt.rec, source)
2705
3269
  let slot: string | null = null
2706
3270
  try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
2707
3271
  // a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
@@ -2726,13 +3290,16 @@ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch:
2726
3290
  try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
2727
3291
  catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
2728
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)
2729
3296
  if (closesCodexBinding && wt.rec.harnessSessionId) {
2730
3297
  bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
2731
3298
  }
2732
3299
  requestQueueDrain() // a close frees a slot — start the next queued session if any
2733
3300
  return true
2734
3301
  }
2735
- async function closeSessionUnlocked(id: string): Promise<boolean> {
3302
+ async function closeSessionUnlocked(id: string, source: CloseSource): Promise<boolean> {
2736
3303
  let wt: { path: string; branch: string | null; rec: SessRec } | null = null
2737
3304
  try { wt = await findWorktree(id) }
2738
3305
  catch (e) {
@@ -2749,13 +3316,30 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
2749
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.`)
2750
3317
  }
2751
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
+ }
2752
3334
  const target = wt
2753
3335
  return target.branch
2754
- ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target))
2755
- : 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)))
2756
3342
  }
2757
- export const closeSession = (id: string): Promise<boolean> =>
2758
- withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
2759
3343
 
2760
3344
  export type CorruptRecordQuarantineWitness = {
2761
3345
  adapter: string
@@ -3096,21 +3680,21 @@ export function statusLegend(color = true): string {
3096
3680
  return c('90', ' key: ') + parts.join(' ')
3097
3681
  }
3098
3682
 
3099
- // 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 +
3100
3684
  // a status legend, so the table tells the whole story (incl. each agent's note) at a glance.
3101
3685
  export function formatTable(sessions: Session[], color = true): string {
3102
3686
  const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
3103
3687
  if (!sessions.length) return c('90', ' no living sessions')
3104
- 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`)
3105
3689
  const rows = sessions.map((s) => {
3106
3690
  const g = STATUS_GLYPH[s.status] ?? '\u00b7'
3107
3691
  const code = ANSI[s.status] ?? '0'
3108
- const name = padWidth(truncWidth(sessionLabel(s), 22), 22)
3692
+ const title = padWidth(truncWidth(sessionTitle(s), 22), 22)
3109
3693
  const st = s.status.padEnd(13)
3110
3694
  const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
3111
3695
  const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)) // what it was asked to do
3112
3696
  const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
3113
- 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}`
3114
3698
  })
3115
3699
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
3116
3700
  }
@@ -3123,27 +3707,72 @@ export function formatTable(sessions: Session[], color = true): string {
3123
3707
  // A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
3124
3708
  // that cannot reach an agent must at least leave a trace ([[session-timeline]]).
3125
3709
  // (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
3126
- 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> {
3127
3720
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
3128
- const rec = readRecord(id)
3129
- if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
3130
- // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
3131
- // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
3132
- // a message that was already accepted.
3133
- const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3721
+ let replayed = false
3134
3722
  try {
3135
- await withRecordLock(id, async () => {
3136
- const appended = appendSent(id, text, from ?? null, prompt.replyVia)
3137
- 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()
3138
3762
  })
3139
3763
  } catch (error) {
3140
- 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
+ }
3141
3770
  }
3142
3771
  // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
3143
3772
  // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
3144
3773
  // the text in a live agent's current turn instead of up to one tick later.
3145
- await drainSession(id)
3146
- return { ok: true }
3774
+ if (!opts.deferDrain) await drainSession(id)
3775
+ return { ok: true, ...(opts.idempotency ? { replayed } : {}) }
3147
3776
  }
3148
3777
 
3149
3778
  // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
@@ -3155,6 +3784,12 @@ export async function drainSession(id: string): Promise<void> {
3155
3784
  if (!rec) return
3156
3785
  const h = harnessById(rec.harness || defaultHarness.id)
3157
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
+ }
3158
3793
  // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3159
3794
  // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
3160
3795
  // rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
@@ -3163,7 +3798,9 @@ export async function drainSession(id: string): Promise<void> {
3163
3798
  if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
3164
3799
  } catch { /* no pane to consult — let the insert itself decide */ }
3165
3800
  }
3166
- 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
3167
3804
  })
3168
3805
  }
3169
3806