spexcode 0.5.2 → 0.5.3

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 (151) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +946 -71
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +47 -8
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +193 -37
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +49 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +99 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1249 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +26 -6
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
@@ -1,17 +1,21 @@
1
- import { execFile } from 'node:child_process'
1
+ import { execFile, spawn } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
3
  import { randomUUID } from 'node:crypto'
4
- import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
4
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, openSync, closeSync, unlinkSync, writeSync } from 'node:fs'
5
5
  import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { seedWorktreeHostState } from './worktree-sources.js'
8
8
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
9
9
  import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs.js'
10
- import { defaultHarness, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
10
+ import { adapterLoadedReferenceState, defaultHarness, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
11
11
  import { materialize } from './materialize.js'
12
- import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
12
+ import { mainBranch, 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'
13
13
  import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
14
14
  import { stripRefSigil } from './mentions.js'
15
+ import { shQuote } from './sh.js'
16
+ import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
17
+ import { processStartToken } from './process-identity.js'
18
+ import { maintenanceBrokerDescriptors, runSessionOperation, runSessionOperationSync, SessionMaintenanceError, type Authorization, type MaintenanceTicket } from './session-maintenance.js'
15
19
 
16
20
  // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
17
21
  // SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
@@ -72,26 +76,31 @@ function maxActive(): number {
72
76
  return Math.max(1, Math.floor(v ?? DEFAULT_MAX_ACTIVE))
73
77
  }
74
78
 
75
- // the rendezvous control socket path + its prompt-delivery/liveness logic now live in the [[harness-adapter]]
76
- // (claude OWNS the rendezvous; codex does not), so product code asks the adapter rather than hard-wiring it.
77
- // rvSock is imported only for the two NON-delivery uses that remain product-level: building the launch env var
78
- // (rvEnv, below) and the best-effort socket sweep on close.
79
- // env prefix put in front of the spawned agent so it creates this session's rendezvous control socket — and
80
- // so its hooks + materialize write to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
79
+ // The adapter owns any transport bootstrap env (rendezvous daemon + socket); product launch only composes it
80
+ // with the governed session id and home vars. This env prefix also ensures hooks + materialize write to the
81
+ // SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
81
82
  // propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
82
83
  // an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
83
84
  // ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
84
85
  const rvEnv = (id: string, harness = HARNESS) => {
85
- // SPEXCODE_SESSION_ID is the governed record id. Claude's harness id is the same value, so hooks and CLI
86
- // calls can use it directly. Codex cannot trust this env inside the long-lived shared app-server; codex hooks
87
- // start from the payload thread id and alias through harness_session_id, while the short-lived codex-launch
88
- // process uses this env only to store the freshly started thread id on the governed record. The CLAUDE_BG
89
- // rendezvous control socket is the reclaude prompt-delivery path and exists ONLY for harnesses that own one
90
- // (claude/pi/opencode) codex has no such daemon, so it is omitted there.
91
- const parts = [`SPEXCODE_SESSION_ID=${id}`]
92
- if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
93
- for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
94
- return parts.join(' ')
86
+ // SPEXCODE_SESSION_ID is the governed record id, and it is the SESSION'S OWN so the launch STRIPS every
87
+ // session-identity variable it may have inherited (the pane inherits the tmux SERVER's env, which may carry
88
+ // a foreign session's ids from whoever started it) before setting this one. Identity is established HERE,
89
+ // once, at the boundary; nothing downstream re-verifies it, because after this a session-identity variable
90
+ // exists in a process only if that process belongs to that session either set right here, or stamped by
91
+ // the harness itself for its own acting conversation ([[harness-adapter]]). The same strip runs on the one
92
+ // other process we own that is NOT a session's own — codex's shared app-server, whose leaked inherited id
93
+ // was github#76.
94
+ const scrub = sessionIdentityEnvVars().map((v) => `-u ${v}`)
95
+ const homeVars = ['SPEXCODE_HOME', 'CODEX_HOME'].flatMap((v) => {
96
+ const value = process.env[v]
97
+ return value ? [`${v}=${value}`] : []
98
+ })
99
+ return [...scrub,
100
+ `SPEXCODE_SESSION_ID=${id}`,
101
+ `SPEXCODE_SESSION_IDENTITY_VARS=${shQuote(sessionIdentityEnvVars().join(','))}`,
102
+ `SPEXCODE_PROJECT_ROOT=${shQuote(mainRoot())}`,
103
+ ...harness.launchEnv(id), ...homeVars].join(' ')
95
104
  }
96
105
 
97
106
  // the prompt-dispatch outcome type + its claude/codex delivery implementations live in the [[harness-adapter]]
@@ -99,9 +108,12 @@ const rvEnv = (id: string, harness = HARNESS) => {
99
108
  // for the existing importers (client.ts) that read it off the sessions module.
100
109
  export type { DispatchResult }
101
110
 
102
- export type Lifecycle = 'active' | 'idle' | 'awaiting' | 'parked' | 'error' | 'asking' | 'queued'
103
- export type Proposal = 'merge' | 'nothing' | 'close'
104
- export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown'
111
+ export type Lifecycle = SessionLifecycle
112
+ export type Proposal = SessionProposal
113
+ // `corrupt` and `retired` are the two RECORD-INTEGRITY readings neither a lifecycle the agent authored nor a
114
+ // liveness the runtime probed, but the honest answer when the record itself can no longer carry either: its
115
+ // bytes don't parse, or the worktree it names is gone. They exist so such a row can never silently vanish.
116
+ export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown' | 'corrupt' | 'retired'
105
117
  // liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
106
118
  // authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
107
119
  // override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
@@ -117,9 +129,11 @@ export type Session = {
117
129
  raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
118
130
  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
119
131
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
120
- capabilities: { headless: boolean; messageStream: boolean } // stable adapter projection; console surfaces consume data, never harness ids
132
+ capabilities: { headless: boolean } // stable adapter projection; console surfaces consume data, never harness ids
121
133
  launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
122
134
  lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
135
+ archived: boolean // cold storage ([[archive]]) — successful records are offline; default views exclude them
136
+ archiveHazard?: string | null // explicit legacy/invariant violation; never hidden as a clean archive
123
137
  prompt: string | null; promptPreview: string | null; created: number; activity: string | null
124
138
  sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
125
139
  }
@@ -228,12 +242,17 @@ export type SessRec = {
228
242
  parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
229
243
  status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
230
244
  sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
245
+ stopped: boolean // explicit human stop; liveness metadata, never an agent-authored lifecycle value
246
+ archived: boolean // shelved by the human ([[archive]]) — only clean after coldProof is written
247
+ coldProof?: string | null // durable exact leaf + adapter unload proof; missing on legacy archives => visible hazard
248
+ adapterRecovery?: string | null // explicit adapter recovery state after an uncertain partial cold mutation
231
249
  launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
232
250
  launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
233
251
  launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
252
+ launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
234
253
  }
235
- const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
236
- const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
254
+ type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
255
+ type LaunchReadinessPending = { version: 1; startedAt: number; original: LaunchReadinessOriginal }
237
256
  export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
238
257
 
239
258
  // @@@ stable launch authority - the supervisor injects its PUBLIC proxy URL into every replaceable child.
@@ -264,35 +283,224 @@ export function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner'>, aut
264
283
  // readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
265
284
  // on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
266
285
  function readRecord(id: string): SessRec | null {
267
- const raw = readAliasedRawRecord(id)
268
- if (!raw) return null
269
- return fromRaw(raw)
286
+ const entry = readAliasedRecordEntry(id)
287
+ if (entry.kind === 'absent') return null
288
+ if (entry.kind === 'corrupt') throw new SessionRecordUnusable('corrupt', id, corruptReason(entry))
289
+ try { return fromRaw(entry.raw) }
290
+ catch (error) {
291
+ throw new SessionRecordUnusable('corrupt', id,
292
+ `session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`)
293
+ }
294
+ }
295
+ // @@@ SessionRecordUnusable - the record exists but cannot carry state, for one of two reasons, and BOTH must
296
+ // stop a writer rather than let it invent one. `corrupt`: the bytes don't parse, so writing would DESTROY the
297
+ // evidence of what broke and resurrect the session as a plausible-looking empty shell (the reported failure —
298
+ // a damaged record came back as a valid `idle` record and even had a launch script regenerated for it).
299
+ // `retired`: the work merged and the worktree the record names is gone, so there is nothing left to be active
300
+ // IN. Readers that enumerate (the board) catch this and render the row; writers let it out, loud.
301
+ export class SessionRecordUnusable extends Error {
302
+ constructor(readonly code: 'corrupt' | 'retired', readonly session: string, message: string) {
303
+ super(message)
304
+ this.name = 'SessionRecordUnusable'
305
+ }
306
+ }
307
+ const corruptReason = (e: { path: string; error: string }): string =>
308
+ `session record is unreadable: ${e.path} — ${e.error}. The file is kept as-is; nothing will rewrite it. A close attempt quarantines the bytes and reports the preserved runtime/worktree/branch residue, but cannot signal or delete without an exact owner.`
309
+ // a record whose worktree is gone names work that no longer exists on disk. That is the manual-retirement end
310
+ // state (merged, worktree and branch removed, record left behind), and it is terminal: no lifecycle writer may
311
+ // put such a session back to work, and no launch may be assembled for a directory that isn't there.
312
+ function retirementReason(rec: SessRec): string | null {
313
+ if (!rec.worktreePath || existsSync(rec.worktreePath)) return null
314
+ return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`
315
+ }
316
+ // the read every LIFECYCLE writer uses: it additionally refuses a retired record. Metadata verbs (rename,
317
+ // sort, archive) and `close` deliberately keep using readRecord — filing and removal stay available on a row
318
+ // whose work is gone.
319
+ function readLiveRecord(id: string): SessRec | null {
320
+ const rec = readRecord(id)
321
+ if (!rec) return null
322
+ const retired = retirementReason(rec)
323
+ if (retired) throw new SessionRecordUnusable('retired', rec.session, retired)
324
+ return rec
325
+ }
326
+
327
+ // Cross-process lifecycle serialization. Hooks and operator commands are separate CLI processes, so the
328
+ // in-memory transition tail is only an optimization. This lock covers each read/modify/write or destructive
329
+ // transition across archive/resume/stop/close and hook writers. It lives outside the record directory so close
330
+ // may remove the record while its lock is held. A dead writer's lock is reclaimed; a live writer is waited for
331
+ // with a bounded wall and then fails loudly rather than allowing a stale write to win.
332
+ const recordLockRoot = () => join(runtimeRoot(), '.session-locks')
333
+ const recordLockPath = (id: string) => join(recordLockRoot(), `${id}.lock`)
334
+ const syncPause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
335
+ function acquireRecordLockSync(id: string, timeoutMs = 30_000): () => void {
336
+ mkdirSync(recordLockRoot(), { recursive: true })
337
+ const path = recordLockPath(id), deadline = Date.now() + timeoutMs
338
+ for (;;) {
339
+ try {
340
+ const fd = openSync(path, 'wx')
341
+ writeSync(fd, String(process.pid))
342
+ closeSync(fd)
343
+ return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
344
+ } catch (e) {
345
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
346
+ let owner = 0
347
+ try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
348
+ if (owner && owner !== process.pid) {
349
+ try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
350
+ }
351
+ if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
352
+ syncPause(10)
353
+ }
354
+ }
355
+ }
356
+ async function acquireRecordLock(id: string, timeoutMs = 30_000): Promise<() => void> {
357
+ mkdirSync(recordLockRoot(), { recursive: true })
358
+ const path = recordLockPath(id), deadline = Date.now() + timeoutMs
359
+ for (;;) {
360
+ try {
361
+ const fd = openSync(path, 'wx')
362
+ writeSync(fd, String(process.pid))
363
+ closeSync(fd)
364
+ return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
365
+ } catch (e) {
366
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
367
+ let owner = 0
368
+ try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
369
+ if (owner && owner !== process.pid) {
370
+ try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
371
+ }
372
+ if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
373
+ await new Promise((resolve) => setTimeout(resolve, 10))
374
+ }
375
+ }
376
+ }
377
+ async function withRecordLock<T>(id: string, body: () => Promise<T>): Promise<T> {
378
+ const release = await acquireRecordLock(id)
379
+ try { return await body() } finally { release() }
380
+ }
381
+ function withRecordLockSync<T>(id: string, body: () => T): T {
382
+ const release = acquireRecordLockSync(id)
383
+ try { return body() } finally { release() }
384
+ }
385
+ function tryRecordLockSync(id: string): (() => void) | null {
386
+ mkdirSync(recordLockRoot(), { recursive: true })
387
+ const path = recordLockPath(id)
388
+ try {
389
+ const fd = openSync(path, 'wx')
390
+ writeSync(fd, String(process.pid))
391
+ closeSync(fd)
392
+ return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
393
+ } catch (e) {
394
+ if ((e as NodeJS.ErrnoException).code === 'EEXIST') return null
395
+ throw e
396
+ }
397
+ }
398
+ // Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
399
+ // enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
400
+ // and then race a just-queued TUI turn.
401
+ export function withSessionInputLock<T>(id: string, body: () => T): T | null {
402
+ // PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
403
+ // regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
404
+ const release = tryRecordLockSync(id)
405
+ if (!release) return null
406
+ try { return body() } finally { release() }
407
+ }
408
+
409
+ const COLD_PROOF_VERSION = 'cold-v1'
410
+ function coldProofFor(rec: Pick<SessRec, 'session' | 'harness' | 'harnessSessionId'>): string {
411
+ const adapter = harnessById(rec.harness || defaultHarness.id).id
412
+ const exact = rec.harnessSessionId ? `thread:${rec.harnessSessionId}` : 'no-resident-ref'
413
+ return `${COLD_PROOF_VERSION}|${adapter}|${rec.session}|${exact}`
414
+ }
415
+ function hasValidColdProof(rec: SessRec): boolean {
416
+ return !!rec.coldProof && rec.coldProof === coldProofFor(rec)
270
417
  }
271
418
  // the loose on-disk fields validated into the typed shape. Exported so the old-record defaults (harness →
272
419
  // claude, absent pin → null) are unit-auditable without a store on disk.
273
420
  export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
274
421
  const ownedQueue = raw.status === OWNED_QUEUE_RAW_STATUS
275
- const status = ownedQueue ? 'queued' : LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
422
+ const status = ownedQueue ? 'queued' : isSessionLifecycle(raw.status) ? raw.status : 'active'
276
423
  const launchOwner = ownedQueue ? raw.launch_owner?.trim() : null
277
424
  if (ownedQueue && !launchOwner) throw new Error(`owned queue record '${raw.session_id}' has no launch_owner`)
278
- const proposal = raw.proposal && PROPOSALS.has(raw.proposal as Proposal) ? raw.proposal as Proposal : null
425
+ const proposal = isSessionProposal(raw.proposal) ? raw.proposal : null
279
426
  const sk = raw.sortkey
280
427
  const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
428
+ const pendingRaw = rawLaunchReadinessOriginal(raw)
429
+ const pendingStatus = pendingRaw && isSessionLifecycle(pendingRaw.status) ? pendingRaw.status : null
430
+ if (pendingRaw && !pendingStatus) throw new Error(`session '${raw.session_id}' launch readiness original has invalid lifecycle '${pendingRaw.status}'`)
431
+ const pendingProposal = pendingRaw && isSessionProposal(pendingRaw.proposal) ? pendingRaw.proposal : null
432
+ if (pendingRaw?.proposal && !pendingProposal) throw new Error(`session '${raw.session_id}' launch readiness original has invalid proposal '${pendingRaw.proposal}'`)
281
433
  return {
282
434
  session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
283
435
  node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
284
436
  status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
285
437
  harness: raw.harness || 'claude', // records written before the harness field default to claude
286
438
  harnessSessionId: raw.harness_session_id || null,
439
+ stopped: !!raw.stopped, // records written before explicit stop tracking were not stopped
440
+ archived: !!raw.archived, // records written before archive → absent → not shelved
441
+ coldProof: raw.cold_proof || null, // legacy archived rows have no proof and remain visible until re-archived
442
+ adapterRecovery: raw.adapter_recovery || null,
287
443
  launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
288
444
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
289
445
  launchOwner: launchOwner || null,
446
+ launchReadinessPending: pendingRaw ? {
447
+ version: 1,
448
+ startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
449
+ original: {
450
+ status: pendingStatus!, proposal: pendingProposal, note: pendingRaw.note || null,
451
+ stopped: pendingRaw.stopped, archived: pendingRaw.archived,
452
+ coldProof: pendingRaw.cold_proof || null, adapterRecovery: pendingRaw.adapter_recovery || null,
453
+ },
454
+ } : null,
290
455
  }
291
456
  }
457
+
458
+ function publicRecord(rec: SessRec): SessRec {
459
+ const original = rec.launchReadinessPending?.original
460
+ return original ? { ...rec, ...original } : rec
461
+ }
462
+
463
+ function launchReadinessPending(original: SessRec): LaunchReadinessPending {
464
+ return {
465
+ version: 1,
466
+ startedAt: Date.now(),
467
+ original: {
468
+ status: original.status,
469
+ proposal: original.proposal,
470
+ note: original.note,
471
+ stopped: original.stopped,
472
+ archived: original.archived,
473
+ coldProof: original.coldProof ?? null,
474
+ adapterRecovery: original.adapterRecovery ?? null,
475
+ },
476
+ }
477
+ }
478
+
479
+ function restoreLaunchReadinessOriginal(rec: SessRec): SessRec {
480
+ const original = rec.launchReadinessPending?.original
481
+ return original ? { ...rec, ...original, launchReadinessPending: null } : rec
482
+ }
483
+ // @@@ the ONE record writer - every field of session.json is produced HERE, by serializing the typed record,
484
+ // and lands by atomic replace (temp file in the same dir, then rename). Nothing else — no hook, no shell, no
485
+ // route — may compose or edit the file's text: a note is arbitrary human/agent prose, so any writer that
486
+ // substitutes it into existing JSON eventually meets a quote, a backslash, or a newline and leaves a record
487
+ // nothing can parse (the reported corruption came from exactly that: a hot-path hook editing the value with
488
+ // sed). The shell hooks READ this file cheaply and delegate every WRITE back through the CLI to this function.
489
+ // The rename is what makes a reader between two writes see one whole record instead of a truncated one.
490
+ //
292
491
  // @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
293
- // present (nulls rendered as "" / the empty value, never an absent key). That stable shape is the contract the
294
- // pure-shell hot-path hook (mark-active) relies on: it value-replaces `"status"`/`"proposal"`/`"note"` with a
295
- // single sed and never needs jq on the user's box. So do NOT switch to conditional keys or a compact dump.
492
+ // present (nulls rendered as "" / the empty value, never an absent key). The stable shape is what lets the
493
+ // pure-shell hooks answer "is this record already active, with nothing stale to clear?" with three exact-line
494
+ // greps and no jq a READ fast path, never an edit. So do NOT switch to conditional keys or a compact dump.
495
+ //
496
+ // @@@ the record self-cleans - the object below is a CLOSED key set rebuilt from the typed record on every
497
+ // write, never a merge over what was read. So a field retired from the code is ALSO retired from disk the
498
+ // next time anything touches that record: no migration verb, no GC pass, no accreting graveyard of dead keys.
499
+ // A new field earns its place by being declared HERE and in `fromRaw` — that pairing is what keeps the file a
500
+ // projection of the current type rather than a log of everything it has ever been, and it is also the reason
501
+ // a field MISSING from this object is silently dropped: `stopped`, `archived`, `cold_proof`, and
502
+ // `adapter_recovery` are distinct lifecycle/resource projection fields, so each must be listed and cleared by
503
+ // its own transition rather than one being inferred from another.
296
504
  function writeRecord(rec: SessRec): void {
297
505
  let previous: SessRec | null = null
298
506
  try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
@@ -316,38 +524,44 @@ function writeRecord(rec: SessRec): void {
316
524
  createdAt: rec.createdAt,
317
525
  harness: rec.harness || 'claude',
318
526
  harness_session_id: rec.harnessSessionId ?? '',
527
+ stopped: rec.stopped,
528
+ archived: rec.archived,
529
+ cold_proof: rec.coldProof ?? '',
530
+ adapter_recovery: rec.adapterRecovery ?? '',
319
531
  launcher: rec.launcher ?? '',
320
532
  launch_cmd: rec.launchCmd ?? '',
321
533
  launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
534
+ launch_readiness_pending: rec.launchReadinessPending ? {
535
+ version: 1,
536
+ startedAt: rec.launchReadinessPending.startedAt,
537
+ original: {
538
+ status: rec.launchReadinessPending.original.status,
539
+ proposal: rec.launchReadinessPending.original.proposal ?? '',
540
+ note: rec.launchReadinessPending.original.note ?? '',
541
+ stopped: rec.launchReadinessPending.original.stopped,
542
+ archived: rec.launchReadinessPending.original.archived,
543
+ cold_proof: rec.launchReadinessPending.original.coldProof ?? '',
544
+ adapter_recovery: rec.launchReadinessPending.original.adapterRecovery ?? '',
545
+ },
546
+ } : '',
322
547
  }
323
- mkdirSync(sessionStoreDir(rec.session), { recursive: true })
324
- writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
325
- // session.json is only the CURRENT projection. Persist each moved lifecycle value before this writer
326
- // returns, so a later write cannot erase a declaration note between observer samples. New-record genesis
327
- // stays with superviseTimeline; metadata-only writes do not manufacture status events.
328
- if (rec.governed && previous && (previous.status !== rec.status
329
- || previous.proposal !== rec.proposal || previous.note !== rec.note)) {
330
- recordStatus(rec.session, rec.status, rec.proposal, rec.note)
331
- }
332
- }
333
-
334
- // @@@ fail-loud enumeration - the worktree set is the board's EXISTENCE truth, so a failed enumeration must
335
- // NEVER masquerade as an empty repo. `gitA` swallows a git error to '' (→ zero rows), which a caller would
336
- // read as "every worktree was removed" — exactly the false mass-`closed` watchSessions would emit once the
337
- // flicker debounce is gone. `git worktree list` ALWAYS lists at least the main worktree, so an ok run with
338
- // zero `worktree ` lines is itself a failure. Both cases THROW; the caller (listSessions) propagates and
339
- // watchSessions' poll `catch` simply skips the tick with `prev` intact — no fabricated removals.
340
- async function listWorktrees(): Promise<{ path: string; branch: string | null }[]> {
341
- const r = await gitTry(['-C', mainRoot(), 'worktree', 'list', '--porcelain'])
342
- if (!r.ok) throw new Error(`git worktree list failed: ${r.stderr.trim() || 'unknown error'}`)
343
- const list: { path: string; branch: string | null }[] = []
344
- let cur: { path: string; branch: string | null } | null = null
345
- for (const line of r.stdout.split('\n')) {
346
- if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null }; list.push(cur) }
347
- else if (line.startsWith('branch ') && cur) cur.branch = line.slice(7).replace('refs/heads/', '')
548
+ const dir = sessionStoreDir(rec.session)
549
+ mkdirSync(dir, { recursive: true })
550
+ const path = sessionRecordPath(rec.session)
551
+ const tmp = join(dir, `.session.json.${process.pid}.tmp`)
552
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n')
553
+ renameSync(tmp, path) // atomic within the dir: a concurrent reader sees the old record or the new one
554
+ // session.json normally is the current public projection. A launch-readiness candidate is the sole internal
555
+ // exception: its frozen original remains public until validation clears the fence. Persist each PUBLIC moved
556
+ // lifecycle value before this writer returns, so a later write cannot erase a declaration note between
557
+ // observer samples. New-record genesis stays with superviseTimeline; metadata-only writes do not manufacture
558
+ // status events.
559
+ const previousPublic = previous ? publicRecord(previous) : null
560
+ const nextPublic = publicRecord(rec)
561
+ if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
562
+ || previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
563
+ recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note)
348
564
  }
349
- if (!list.length) throw new Error('git worktree list returned no worktrees (enumeration failed; the main worktree is always present)')
350
- return list
351
565
  }
352
566
 
353
567
  // @@@ reconcile - the shown status. awaiting → the proposal's label (review/done/close-pending),
@@ -456,7 +670,12 @@ async function liveSnapshot(): Promise<LiveSnap> {
456
670
  for (const [id, p] of parseLivePanes(out)) {
457
671
  windows.set(id, { panePid: p.panePid, pidAlive: agentAlive(id) })
458
672
  if (p.title) titles.set(id, p.title)
459
- if (windows.get(id)!.pidAlive === undefined) { const rec = readRecord(id); if (rec) legacy.push({ harness: rec.harness, hasPid: false }) }
673
+ if (windows.get(id)!.pidAlive === undefined) {
674
+ // A corrupt row has no trustworthy harness to scan and renders liveness=unknown on its own. Letting this
675
+ // optional legacy enrichment throw would turn one diagnosable row into a 409 for the entire board.
676
+ try { const rec = readRecord(id); if (rec) legacy.push({ harness: rec.harness, hasPid: false }) }
677
+ catch (e) { if (!(e instanceof SessionRecordUnusable)) throw e }
678
+ }
460
679
  }
461
680
  // the whole-box ps table is gathered ONCE, and ONLY for the legacy pid-less-codex fallback (paneTreeRunsCodex).
462
681
  if (needsCodexProcScan(legacy)) {
@@ -574,7 +793,7 @@ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it
574
793
  // sock lingers. A just-launched agent whose online-signal hasn't appeared yet reads the transient 'starting'
575
794
  // for the grace window; only past it (still not online) is it genuinely 'offline'.
576
795
  export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
577
- if (!rec.session) return 'offline'
796
+ if (!rec.session || rec.stopped || rec.archived) return 'offline'
578
797
  // Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
579
798
  // codex proves its launch-registered pid (with the legacy descendant-tree fallback). The 'starting' grace
580
799
  // stays here: a just-launched agent whose online signal has not appeared yet reads 'starting', only past it
@@ -587,7 +806,15 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
587
806
  // act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
588
807
  if (snap.unproven.has(rec.session)) return 'unknown'
589
808
  const at = launchedAt.get(rec.session)
590
- return at && Date.now() - at < BOOT_GRACE_MS ? 'starting' : 'offline'
809
+ if (at && Date.now() - at < BOOT_GRACE_MS) return 'starting'
810
+ // A dead TRANSPORT is not a dead AGENT. The socket path is keyed by session id alone, so a foreign teardown
811
+ // (or a stray rm) can unlink it out from under its own live listener: the agent keeps working, unreachable,
812
+ // and every path-connect ENOENTs — which the adapter axis above reports as proven death. The registered
813
+ // agent.pid is a SECOND, independent witness, and while it still answers, death is UNPROVEN: `unknown`, not
814
+ // the `offline` that disarms the relaunch guard and invites a human to kill a working agent. Same rule as
815
+ // the probe-failure branch (issue #40), one layer down: only a corpse both witnesses agree on is actionable.
816
+ if (agentAlive(rec.session) === true) return 'unknown'
817
+ return 'offline'
591
818
  }
592
819
 
593
820
  // reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
@@ -597,6 +824,10 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
597
824
  // idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
598
825
  // terminal-mount and the relaunch panel on; this label is for badges and `spex session ls`.
599
826
  function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
827
+ // record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
828
+ // about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
829
+ if (retirementReason(rec)) return 'retired'
830
+ if (rec.archived) return 'offline'
600
831
  if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
601
832
  if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
602
833
  const lv = liveness(rec, snap)
@@ -613,6 +844,21 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
613
844
  return { path: rec.worktreePath, branch: rec.branch, rec }
614
845
  }
615
846
 
847
+ // @@@ corruptSession - the row for a record we cannot parse. Every display field the surfaces read is filled
848
+ // from the ONE thing we still know (the id) plus the diagnosis, so the row renders everywhere without any
849
+ // surface having to special-case a half-record. Liveness is `unknown`, not `offline`: we never probed, so we
850
+ // have not proven anything about the agent — the same honesty rule a failed probe follows.
851
+ function corruptSession(id: string, entry: { path: string; error: string }): Session {
852
+ const label = `${id.slice(0, 8)} (unreadable record)`
853
+ return {
854
+ id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
855
+ parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
856
+ lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
857
+ note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
858
+ activity: null, sortKey: null, archiveHazard: null,
859
+ }
860
+ }
861
+
616
862
  export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
617
863
  const prompt = readPromptFile(rec.session) // the originating ask, captured at launch (store artifact; null for old sessions)
618
864
  // activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
@@ -622,7 +868,7 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
622
868
  const pp = prompt ? promptPreview(prompt) : null
623
869
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
624
870
  const harness = harnessById(rec.harness || defaultHarness.id)
625
- 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, messageStream: harness.messageStream }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
871
+ 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 }
626
872
  }
627
873
 
628
874
  // @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
@@ -632,10 +878,12 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
632
878
  // any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
633
879
  // (the route answers 404). The frontend's right-click rename is the sole caller today.
634
880
  export async function renameSession(id: string, name: string): Promise<boolean> {
635
- const wt = await findWorktree(id)
636
- if (!wt) return false
637
- writeRecord({ ...wt.rec, name: name.trim() || null })
638
- return true
881
+ return runSessionOperation({ op: 'rename', sessionId: id }, () => withRecordLock(id, async () => {
882
+ const wt = await findWorktree(id)
883
+ if (!wt) return false
884
+ writeRecord({ ...wt.rec, name: name.trim() || null })
885
+ return true
886
+ }))
639
887
  }
640
888
 
641
889
  // @@@ setSessionSort - set (or clear) a session's drag-reorder pseudo-time ([[session-reorder]]), parallel
@@ -643,15 +891,20 @@ export async function renameSession(id: string, name: string): Promise<boolean>
643
891
  // shows on every surface (all sort by `sortKey ?? created`). A null key CLEARS it, dropping the row back to
644
892
  // its `created` slot. Works in any state since it edits the on-disk record. Unknown id → false (route 404s).
645
893
  export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
646
- const wt = await findWorktree(id)
647
- if (!wt) return false
648
- writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
649
- return true
894
+ return runSessionOperation({ op: 'sort', sessionId: id }, () => withRecordLock(id, async () => {
895
+ const wt = await findWorktree(id)
896
+ if (!wt) return false
897
+ writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
898
+ return true
899
+ }))
650
900
  }
651
901
 
652
- // the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded.
902
+ // the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded. A record we
903
+ // cannot read simply has no prompt to report — a READ accessor must not turn an unreadable record into an
904
+ // error for the surface asking about it; the row itself already carries the diagnosis.
653
905
  export async function sessionPrompt(id: string): Promise<string | null> {
654
- return readRecord(id) ? readPromptFile(id) : null
906
+ try { return readRecord(id) ? readPromptFile(id) : null }
907
+ catch (e) { if (e instanceof SessionRecordUnusable) return null; throw e }
655
908
  }
656
909
 
657
910
  // @@@ lastKnownSession - the last successfully-read Session row per session_id. The record's EXISTENCE in
@@ -665,19 +918,90 @@ const lastKnownSession = new Map<string, Session>()
665
918
  // old `git worktree list` scan). Every GOVERNED record this project owns becomes a row, status reconciled;
666
919
  // non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
667
920
  // Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
668
- export async function listSessions(): Promise<Session[]> {
921
+ export async function listSessions(includeArchived = false): Promise<Session[]> {
669
922
  // ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
670
923
  // every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
671
924
  const [ids, snap] = await Promise.all([
672
925
  Promise.resolve(listSessionIds()), liveSnapshot(),
673
926
  ])
927
+ // Freeze one record snapshot for both the census join and row projection. A second full read after an awaited
928
+ // probe could pair record A with thread identity B and accidentally treat a missing census entry as clean.
929
+ const snapshots = new Map<string, { entry: PublicRecordEntry; rec: SessRec | null }>()
930
+ for (const id of ids) {
931
+ try {
932
+ const entry = readPublicRecordEntry(id)
933
+ snapshots.set(id, { entry, rec: entry.kind === 'ok' ? fromRaw(entry.raw) : null })
934
+ } catch { /* guardSession below preserves the last-known row for a transient read failure */ }
935
+ }
936
+ // Only archived adapter records need the resident-ID join. If there are none, this read path performs zero
937
+ // control-plane probes; resources still owns the full turn/read probe for its detailed report.
938
+ const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && entry.liveness === null && rec && rec.governed && rec.archived && rec.harnessSessionId
939
+ ? [{ ...rec, harness: rec.harness || defaultHarness.id }]
940
+ : [])
941
+ const residentCensus = censusRecords.length ? await adapterLoadedReferenceState(censusRecords) : new Map()
942
+ // A record can change while the one census is in flight. Mark such rows conservatively; never let the stale
943
+ // proof hide them. This is a bounded storage read, not another adapter RPC.
944
+ const changedDuringCensus = new Set<string>()
945
+ for (const rec of censusRecords) {
946
+ try {
947
+ const current = readPublicRecordEntry(rec.session)
948
+ const before = snapshots.get(rec.session)?.entry
949
+ if (current.kind !== 'ok' || before?.kind !== 'ok' || JSON.stringify(current.raw) !== JSON.stringify(before.raw)) changedDuringCensus.add(rec.session)
950
+ } catch { changedDuringCensus.add(rec.session) }
951
+ }
674
952
  const rows = ids.map((id) => guardSession(id, () => {
675
- const rec = readRecord(id)
953
+ // a record we cannot READ still has a row: it is a session that exists and whose state is unknowable, which
954
+ // is a thing to act on, not a thing to hide. It carries its own status and names the file, so the human can
955
+ // see the file and close it — the alternative (dropping it) is what made a live session read as gone.
956
+ const snapshot = snapshots.get(id)
957
+ if (!snapshot) throw new Error(`session ${id} record snapshot unavailable`)
958
+ const { entry } = snapshot
959
+ // The corrupt row becomes the LAST-KNOWN row, never a deletion. Dropping it would mean the next poll that
960
+ // hits a transient read failure has nothing to fall back on and the row vanishes — re-opening the exact
961
+ // hole this branch closes, one poll later. `corrupt` is a true reading, so it is worth remembering.
962
+ if (entry.kind === 'corrupt') { const c = corruptSession(id, entry); lastKnownSession.set(id, c); return c }
963
+ const rec = snapshot.rec
676
964
  if (!rec || !rec.governed) { lastKnownSession.delete(id); return null } // no record, or a self-launched (non-board) one
965
+ // A forced public liveness comes only from the shared record projection. Do not let live process/thread
966
+ // evidence punch through it (including archive hazard repair).
967
+ if (entry.kind === 'ok' && entry.liveness === 'offline') {
968
+ const pending = toSession(rec, 'offline', 'offline')
969
+ lastKnownSession.set(id, pending)
970
+ return pending
971
+ }
677
972
  // the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
678
973
  // is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
679
974
  const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id))
680
- const s = toSession(rec, reconcile(rec, snap), liveness(rec, snap), activity)
975
+ const sessionHarness = harnessById(rec.harness || defaultHarness.id)
976
+ const resident = rec.harnessSessionId
977
+ ? residentCensus.get(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`)
978
+ : undefined
979
+ const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!rec.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length
980
+ const physical = rec.archived
981
+ ? (sessionHarness.runtimeOwnership === 'adapter'
982
+ ? (resident && !resident.healthy ? 'unknown' : resident?.loaded ? 'online' : snap.windows.has(id) ? 'online' : 'offline')
983
+ : liveness({ ...rec, archived: false, stopped: false }, snap))
984
+ : null
985
+ // Only a physically-offline record projects as archived. A legacy archived+live/unknown record is exposed
986
+ // as ordinary working-set state with its real liveness/status and one backend-owned hazard marker. A
987
+ // missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
988
+ // unloaded, so it remains visible until an explicit archive repair.
989
+ const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true)
990
+ const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec
991
+ const projectedLv = projected === rec ? liveness(rec, snap) : physical!
992
+ const s = toSession(projected, reconcile(projected, snap), projectedLv, activity)
993
+ if (projected !== rec) s.archiveHazard = changedDuringCensus.has(id)
994
+ ? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
995
+ : hasValidColdProof(rec)
996
+ ? residentRequired && !resident
997
+ ? 'archived runtime hazard: adapter resident-reference census missing; exact unload is unproven'
998
+ : resident && !resident.healthy
999
+ ? `archived runtime hazard: adapter resident-reference census is unknown (${resident.error || 'probe failed'})`
1000
+ : resident?.loaded
1001
+ ? 'archived runtime hazard: target adapter thread is still loaded'
1002
+ : `archived runtime hazard: record says archived but physical liveness is ${physical}`
1003
+ : 'archived runtime hazard: record has no durable cold witness; exact adapter unload is unproven'
1004
+ if (rec.adapterRecovery) s.archiveHazard = `archive adapter recovery required: ${rec.adapterRecovery}`
681
1005
  lastKnownSession.set(id, s)
682
1006
  return s
683
1007
  }, () => {
@@ -694,7 +1018,8 @@ export async function listSessions(): Promise<Session[]> {
694
1018
  // `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
695
1019
  // overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
696
1020
  // same-instant births (or sort-keys) stay deterministic.
697
- return rows.filter((s): s is Session => s != null).sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
1021
+ return rows.filter((s): s is Session => s != null && (includeArchived || !s.archived))
1022
+ .sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
698
1023
  }
699
1024
 
700
1025
  // a per-session read guard mirroring resilience.guardWorktree but keyed on the store record (not a worktree
@@ -1063,10 +1388,42 @@ export function launcherCmd(rec: SessRec): string | undefined {
1063
1388
  if (rec.launchCmd) return rec.launchCmd
1064
1389
  return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
1065
1390
  }
1391
+ // @@@ launch preflight - the launch transport's OWN settled failures, checked before a tmux window is ever
1392
+ // opened. Each is a fact about this machine right now that no number of attempts can change: the worktree the
1393
+ // agent would run in, the branch it would commit to, the command that would start it. Answering them here is
1394
+ // what turns a certain failure into ONE loud, named refusal instead of a launch that fast-exits and is retried
1395
+ // on a wall clock. Everything the transport CANNOT settle (a launcher that races its own daemon) still reaches
1396
+ // the bounded retry, and the harness's own settled failures are the adapter's to name (fatalLaunchOutput).
1397
+ export type LaunchBlock = { code: 'no-worktree' | 'no-branch' | 'no-launcher'; message: string }
1398
+ // does this ref resolve to a commit? Through git.ts's git() so a hook's exported GIT_DIR can't misdirect
1399
+ // discovery. `--verify --quiet` answers a MISSING ref with a bare non-zero exit and no stderr, while a broken
1400
+ // repo/timeout writes stderr — so only the silent failure is read as absence. A probe that could not answer
1401
+ // reads as "exists": the preflight refuses on a PROVEN absence, never on a failed probe ([[state]]'s board
1402
+ // honesty rule applied to launch).
1403
+ function refExists(cwd: string, ref: string): boolean {
1404
+ try { return !!git(['-C', cwd, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim() }
1405
+ catch (e) { return String((e as { stderr?: string })?.stderr ?? '').trim() !== '' }
1406
+ }
1407
+ export function launchPreflight(rec: SessRec): LaunchBlock | null {
1408
+ if (!rec.worktreePath || !existsSync(rec.worktreePath))
1409
+ return { code: 'no-worktree', message: `session ${rec.session.slice(0, 8)}: its worktree ${rec.worktreePath || '(unrecorded)'} does not exist — there is nothing to launch in. If the work merged and the worktree was removed, close the session; otherwise restore the worktree first.` }
1410
+ if (rec.branch && !refExists(rec.worktreePath, rec.branch))
1411
+ return { code: 'no-branch', message: `session ${rec.session.slice(0, 8)}: its branch ${rec.branch} no longer exists — a relaunch would put the agent on a detached or wrong ref. Restore the branch, or close the session.` }
1412
+ let cmd: string | undefined
1413
+ try { cmd = launcherCmd(rec) }
1414
+ catch (e) { return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its launcher cannot be resolved — ${e instanceof Error ? e.message : e}` } }
1415
+ // only an ABSOLUTE command is checkable here; a bare name is the shell's PATH lookup at launch time, and
1416
+ // guessing at it would refuse launches that work. Certainty is the whole point of a preflight.
1417
+ const bin = (cmd ?? '').trim().split(/\s+/)[0]
1418
+ if (bin && isAbsolute(bin) && !existsSync(bin))
1419
+ return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its pinned launcher command ${bin} is not on this machine — every launch of it will fail until the path is restored or the session is re-dispatched under a launcher that exists.` }
1420
+ return null
1421
+ }
1422
+
1066
1423
  // @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
1067
1424
  // invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
1068
1425
  const shq1 = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
1069
- export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
1426
+ export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): string {
1070
1427
  const file = join(storeDir(id), 'launch.sh')
1071
1428
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
1072
1429
  // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
@@ -1094,24 +1451,65 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1094
1451
  // A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
1095
1452
  // app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
1096
1453
  // retry loop is a runtime capability rather than a harness-id branch.
1454
+ // @@@ retry only what retrying can fix - a fast exit says the launcher stopped before readiness, which is
1455
+ // reason enough to try again but never a diagnosis. So after a fast exit the script reads what the harness
1456
+ // actually SAID and matches it against the ADAPTER's own settled-failure patterns ([[harness-adapter]]
1457
+ // fatalLaunchOutput). A match means this command cannot succeed however many times we run it: stop at one
1458
+ // attempt and let the harness's own line be the last thing on the pane, instead of spending a certain failure
1459
+ // three times and burying the reason. No match keeps the plain bounded retry.
1460
+ //
1461
+ // It reads the PANE, not the agent's streams. Capturing stderr through a pipe missed the answer entirely —
1462
+ // measured against real reclaude, "No conversation found with session ID" arrives on STDOUT, so a
1463
+ // stderr-only capture classified nothing and retried a certain failure three times (the unit test passed
1464
+ // only because its stub printed to the stream the implementation happened to watch). Redirecting stdout too
1465
+ // would be worse: a TUI that finds stdout is not a terminal stops being a TUI. The pane already holds both
1466
+ // streams exactly as the human sees them, and the script runs inside that pane — so it just asks tmux.
1467
+ const fatal = (harness.fatalLaunchOutput ?? []).join('|')
1097
1468
  const launchBody = harness.launchOneShot ? [born, ''] : [
1098
1469
  `for __spex_try in 1 2 3; do`,
1099
1470
  ` __spex_t0=$SECONDS`,
1471
+ // @@@ classify THIS attempt only - the pane is a scrollback, so it also holds every earlier attempt and
1472
+ // every earlier launch that ever ran in this window. Matching the whole capture would let a stale
1473
+ // settled-failure line from minutes ago condemn an unrelated fast exit and cut a launch that retrying
1474
+ // WOULD have recovered — the exact mirror of the miss this classifier exists to fix. So each attempt
1475
+ // stamps a line unique to (this run, this attempt) and the match starts after it. The run's pid is what
1476
+ // makes it unique across relaunches, which reuse the session id.
1477
+ ` __spex_mark="attempt $__spex_try start $$"`,
1478
+ ` printf '[spex launch] %s\\n' "$__spex_mark"`,
1100
1479
  ` ${born}`,
1101
1480
  ` __spex_rc=$?`,
1102
1481
  ` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
1482
+ ...(fatal ? [
1483
+ // -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
1484
+ // can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
1485
+ // bounded retry stands.
1486
+ ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shq1(fatal)}; then`,
1487
+ ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
1488
+ ` exit $__spex_rc`,
1489
+ ` fi`,
1490
+ ] : []),
1103
1491
  ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
1104
1492
  ` sleep 2`,
1105
1493
  `done`,
1106
1494
  `exit $__spex_rc`,
1107
1495
  ``,
1108
1496
  ]
1109
- writeFileSync(file, launchBody.join('\n'))
1497
+ const delegatePreamble = delegateFifo ? [
1498
+ `exec 9<${shq1(delegateFifo)}`,
1499
+ `rm -f ${shq1(delegateFifo)}`,
1500
+ 'export SPEXCODE_MAINTENANCE_DELEGATE_FD=9',
1501
+ `export SPEXCODE_MAINTENANCE_SESSION_ID=${shq1(id)}`,
1502
+ ] : []
1503
+ writeFileSync(file, [...delegatePreamble, ...launchBody].join('\n'))
1110
1504
  return file
1111
1505
  }
1112
- async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
1506
+ async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): Promise<void> {
1507
+ // record the transport path THIS runtime hands the agent, before anything reads it (launchScript bakes it
1508
+ // into the launch env). Same kind of launch-time fact as agent.pid, and the reason a session's socket is
1509
+ // reachable only from the world it belongs to ([[harness-adapter]] rendezvous socket).
1510
+ if (harness.ownsRendezvous) stampRvSock(id)
1113
1511
  await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path])
1114
- await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
1512
+ await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd, delegateFifo)}`])
1115
1513
  await tmux(['send-keys', '-t', id, 'Enter'])
1116
1514
  launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
1117
1515
  }
@@ -1140,16 +1538,46 @@ function isOccupying(s: Session, snap: LiveSnap): boolean {
1140
1538
  // In-memory in the single server process (the only drainer) — lost on restart, which is fine: a restart drains
1141
1539
  // the durable `queued` worktrees fresh with nothing in flight.
1142
1540
  const launching = new Set<string>()
1541
+ // A queued launch and a cold archive must not cross between their final read and record write. This is a
1542
+ // narrow per-session intent latch, not a second cleanup primitive; the launch path simply leaves an archiving id
1543
+ // alone and the archive path re-probes before filing.
1544
+ const archiving = new Set<string>()
1545
+ const transitionTails = new Map<string, Promise<void>>()
1546
+ async function withSessionTransition<T>(id: string, body: () => Promise<T>): Promise<T> {
1547
+ const previous = transitionTails.get(id) ?? Promise.resolve()
1548
+ let release!: () => void
1549
+ const current = new Promise<void>((resolve) => { release = resolve })
1550
+ transitionTails.set(id, current)
1551
+ await previous
1552
+ try { return await body() }
1553
+ finally {
1554
+ release()
1555
+ if (transitionTails.get(id) === current) transitionTails.delete(id)
1556
+ }
1557
+ }
1143
1558
  let draining = false // re-entrancy guard: only one drain pass runs at a time (no double-launch)
1144
1559
 
1145
1560
  // launch a prepared `queued` worktree: feed it its parked launch prompt, flip it to active. Returns false
1146
1561
  // (leaving it queued, to be retried next drain) if the worktree/prompt is gone or the tmux launch threw.
1147
- async function startQueued(id: string): Promise<boolean> {
1562
+ async function startQueuedUnlocked(id: string): Promise<boolean> {
1563
+ if (archiving.has(id)) return false
1148
1564
  const wt = await findWorktree(id)
1149
1565
  if (!wt) return false
1566
+ if (archiving.has(id) || wt.rec.archived) return false
1150
1567
  if (!canDrainQueued(wt.rec)) return false
1151
1568
  const launchPrompt = readLaunchFile(id)
1152
1569
  if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
1570
+ // a queued worktree can go missing while it waits (a human cleaned up, a disk moved). Draining it would open
1571
+ // a window that fast-exits and burn the retry budget every tick, so refuse ONCE, loudly, and stamp the reason
1572
+ // on the record — the drainer then leaves it alone instead of spinning on a launch that cannot work.
1573
+ const blocked = launchPreflight(wt.rec)
1574
+ if (blocked) {
1575
+ if (wt.rec.note !== blocked.message) {
1576
+ console.error(`spex: not launching queued session ${id}: ${blocked.message}`)
1577
+ writeRecord({ ...wt.rec, note: blocked.message })
1578
+ }
1579
+ return false
1580
+ }
1153
1581
  launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
1154
1582
  const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
1155
1583
  try {
@@ -1166,13 +1594,14 @@ async function startQueued(id: string): Promise<boolean> {
1166
1594
  void waitForReady(id, h).finally(() => launching.delete(id))
1167
1595
  return true
1168
1596
  }
1597
+ const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
1169
1598
 
1170
1599
  // @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
1171
1600
  // re-entrancy-guarded; safe to call on every slot-freeing event (newSession / close / propose) AND on a
1172
1601
  // periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
1173
1602
  // (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
1174
1603
  // so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
1175
- export async function drainQueue(): Promise<void> {
1604
+ async function drainQueueUnlocked(): Promise<void> {
1176
1605
  if (draining) return
1177
1606
  draining = true
1178
1607
  try {
@@ -1197,6 +1626,14 @@ export async function drainQueue(): Promise<void> {
1197
1626
  }
1198
1627
  } finally { draining = false }
1199
1628
  }
1629
+ export const drainQueue = (): Promise<void> => runSessionOperation({ op: 'queue-drain' }, drainQueueUnlocked)
1630
+ const requestQueueDrain = (): void => {
1631
+ void drainQueue().catch((error) => {
1632
+ // An exact maintenance stop deliberately frees no unrelated queue work while admission is closed.
1633
+ if (error instanceof SessionMaintenanceError && error.code === 'maintenance_active') return
1634
+ console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`)
1635
+ })
1636
+ }
1200
1637
 
1201
1638
  // @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
1202
1639
  // newSession/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
@@ -1264,11 +1701,14 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
1264
1701
  if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
1265
1702
  const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
1266
1703
  const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
1267
- try {
1268
- return { status: 201, session: await create(prompt, parent, launcher) }
1269
- } catch (e) {
1270
- return { status: 400, error: String((e as Error).message || e) }
1271
- }
1704
+ return runSessionOperation({ op: 'create' }, async () => {
1705
+ try {
1706
+ return { status: 201, session: await create(prompt, parent, launcher) }
1707
+ } catch (e) {
1708
+ if (e instanceof SessionMaintenanceError) throw e
1709
+ return { status: 400, error: String((e as Error).message || e) }
1710
+ }
1711
+ })
1272
1712
  }
1273
1713
 
1274
1714
  // @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
@@ -1280,6 +1720,9 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
1280
1720
  // reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
1281
1721
  // handler calls newSession directly, so it never re-enters this path.
1282
1722
  export async function createSession(prompt: string, launcher?: string): Promise<Session> {
1723
+ if (maintenanceBrokerDescriptors()) {
1724
+ throw new SessionMaintenanceError('maintenance_capability_missing', 'maintenance operator broker admits only its exact stop/resume plan', { operation: 'create' })
1725
+ }
1283
1726
  await assertProjectMatch('spex session new')
1284
1727
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
1285
1728
  // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
@@ -1295,7 +1738,7 @@ export async function createSession(prompt: string, launcher?: string): Promise<
1295
1738
  })
1296
1739
  } catch {
1297
1740
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
1298
- return newSession(prompt, parent, launcher)
1741
+ return runSessionOperation({ op: 'fallback-create' }, () => newSession(prompt, parent, launcher))
1299
1742
  }
1300
1743
  if (!res.ok) {
1301
1744
  const text = await res.text().catch(() => '')
@@ -1308,6 +1751,23 @@ export async function createSession(prompt: string, launcher?: string): Promise<
1308
1751
  return await res.json() as Session
1309
1752
  }
1310
1753
 
1754
+ // @@@ spawnerClause - where the SPAWNER works, told to the child ([[spawner-pointer]]). A child's worktree is
1755
+ // branched off the BASE branch, never off its spawner, so everything that session has in flight — a spec node
1756
+ // it just created, an edit it hasn't landed — is absent from the child's tree AND from the spec index the
1757
+ // pointer above resolves against (that index reads the backend's own checkout). Teaching the fork or the
1758
+ // landing about nesting would cost a second base per session and would carry the spawner's unreviewed commits
1759
+ // into whatever the child merges into; naming the spawner's worktree costs one line and lets the agent decide.
1760
+ // A POINTER, never a body — same family rule as [[spec-pointer]] — and fail-quiet by absence: no parent, or a
1761
+ // parent record without a worktree, appends nothing.
1762
+ export function spawnerClause(p: SessRec | null): string {
1763
+ if (!p?.worktreePath) return ''
1764
+ const who = p.name || p.title
1765
+ return `\n\nYou were created by session \`${p.session.slice(0, 8)}\`${who ? ` (${who})` : ''}, whose worktree is ${p.worktreePath}` +
1766
+ `${p.branch ? ` on branch \`${p.branch}\`` : ''}. Your own worktree is branched from \`${mainBranch()}\`, so it does NOT contain that ` +
1767
+ `session's uncommitted or unmerged work — a spec node it just created, an edit it hasn't landed. If your task needs anything of theirs, ` +
1768
+ `read it there directly. Read only: never write into another session's worktree.`
1769
+ }
1770
+
1311
1771
  // @@@ newSession - durable worktree (branch node/<slug> off main) + a global session.json record. The agent does NOT
1312
1772
  // launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
1313
1773
  // immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
@@ -1334,14 +1794,13 @@ export async function newSession(prompt: string, parent: string | null = null, l
1334
1794
  const branch = `node/${slug}`
1335
1795
  const path = join(mainRoot(), '.worktrees', slug)
1336
1796
  // Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
1337
- // preset resolution breaks. The optional spec pointer is a seam input; the note insert remains last.
1797
+ // preset resolution breaks. The optional spec + spawner pointers are seam inputs; the note insert remains last.
1338
1798
  const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
1339
- const suffix = spec
1340
- ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1341
- : undefined
1799
+ const suffix = (spec ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.` : '')
1800
+ + spawnerClause(parent ? readRecord(parent) : null)
1342
1801
  const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
1343
1802
  loadedSpecs: launchSpecs ?? undefined,
1344
- suffix,
1803
+ suffix: suffix || undefined,
1345
1804
  })).text
1346
1805
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1347
1806
  // the checkout delivers the tracked spec sources and the materialize below delivers the materialized
@@ -1360,7 +1819,7 @@ export async function newSession(prompt: string, parent: string | null = null, l
1360
1819
  // mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
1361
1820
  node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
1362
1821
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
1363
- harness: h.id, harnessSessionId: null, launcher: chosen.name,
1822
+ harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
1364
1823
  // PIN the resolved launch command NOW ([[launcher-select]] resume-launcher-pin) so every future
1365
1824
  // (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
1366
1825
  // re-resolving against a default that may have flipped (a backend restarted under a different launcher).
@@ -1378,12 +1837,11 @@ export async function newSession(prompt: string, parent: string | null = null, l
1378
1837
  writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
1379
1838
  await drainQueue() // launch now if under the cap, else leave it queued for a free slot
1380
1839
  const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
1381
- // Every adapter answers from its own truth. Asking with no process facts distinguishes a record-backed
1382
- // adapter (online immediately) from process-backed queued/booting adapters (offline/starting) without an
1383
- // extra whole-box tmux snapshot on the creation hot path.
1384
- const recordOnline = h.liveness(after, false, runtimeRoot()) === 'online'
1840
+ // Every headless adapter is record-backed and therefore online immediately; interactive adapters still need
1841
+ // their real process/transport snapshot. Read that capability directly instead of smuggling a harness-mode
1842
+ // probe through liveness with fake tmuxAlive=false.
1385
1843
  const queued = after.status === 'queued'
1386
- return toSession(after, queued ? 'queued' : 'working', recordOnline ? 'online' : queued ? 'offline' : 'starting')
1844
+ return toSession(after, queued ? 'queued' : 'working', h.headless ? 'online' : queued ? 'offline' : 'starting')
1387
1845
  }
1388
1846
 
1389
1847
  // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
@@ -1417,13 +1875,29 @@ const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fai
1417
1875
  // waitForReady (slot-hold + resume) waits through a daemon-race retry
1418
1876
  // instead of returning before a recovering socket
1419
1877
  const SOCKET_POLL_MS = 200
1420
- async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<boolean> {
1878
+ async function waitForReady(id: string, harness: Harness, pending?: SessRec, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<HarnessLaunchReadinessFence | null> {
1879
+ const current = () => {
1880
+ const stored = readRecord(id)
1881
+ const rec = stored && pending
1882
+ ? { ...pending, ...stored, stopped: pending.stopped, archived: pending.archived }
1883
+ : stored || pending
1884
+ return rec ? { ...rec, runtimeDir: runtimeRoot() } : null
1885
+ }
1421
1886
  const deadline = Date.now() + timeoutMs
1887
+ if (harness.launchReady) return harness.launchReady(current, deadline)
1888
+ const genericFence = (): HarnessLaunchReadinessFence => ({
1889
+ proof: Object.freeze({ kind: 'adapter-liveness', harnessId: harness.id, sessionId: id }),
1890
+ validate: async (latest) => {
1891
+ const rec = latest()
1892
+ const snap = await liveSnapshot()
1893
+ return !!rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online'
1894
+ },
1895
+ })
1422
1896
  for (;;) {
1423
- const rec = readRecord(id)
1897
+ const rec = current()
1424
1898
  const snap = await liveSnapshot() // window + pane probe + live-listener set in one snapshot — all the adapter needs
1425
- if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online') return true
1426
- if (Date.now() >= deadline) return false
1899
+ if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online') return genericFence()
1900
+ if (Date.now() >= deadline) return null
1427
1901
  await new Promise((r) => setTimeout(r, SOCKET_POLL_MS))
1428
1902
  }
1429
1903
  }
@@ -1450,26 +1924,165 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
1450
1924
  // session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
1451
1925
  // a refusal leaves the record wholly untouched.
1452
1926
  // Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
1453
- export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
1927
+ type ResumeOptions = { force?: boolean; guard?: boolean; authorization?: Authorization }
1928
+ type ResumeExecutionOptions = ResumeOptions & { ticket?: MaintenanceTicket }
1929
+
1930
+ async function delegatedSpawnTransfer(id: string, ticket: MaintenanceTicket): Promise<{
1931
+ fifo: string
1932
+ done: Promise<void>
1933
+ close(): void
1934
+ }> {
1935
+ const fifo = join(storeDir(id), `.maintenance-delegate-${randomUUID()}.fifo`)
1936
+ await pexec('mkfifo', ['-m', '600', fifo])
1937
+ const bearer = ticket.delegateSharedSpawn(id)
1938
+ const writer = spawn('sh', ['-c', 'cat > "$1"', 'spex-maintenance-delegate', fifo], {
1939
+ stdio: ['pipe', 'ignore', 'pipe'],
1940
+ })
1941
+ let stderr = ''
1942
+ writer.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk })
1943
+ writer.stdin.on('error', () => {})
1944
+ writer.stdin.end(bearer)
1945
+ let settled = false
1946
+ const done = new Promise<void>((resolve, reject) => {
1947
+ writer.once('error', (error) => { settled = true; reject(error) })
1948
+ writer.once('close', (code, signal) => {
1949
+ settled = true
1950
+ if (code === 0) resolve()
1951
+ else reject(new SessionMaintenanceError('maintenance_delegate_invalid',
1952
+ `shared-spawn delegate transfer failed (${signal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''})`,
1953
+ { operation: 'shared-spawn', sessionId: id }))
1954
+ })
1955
+ })
1956
+ return {
1957
+ fifo,
1958
+ done,
1959
+ close() {
1960
+ if (!settled) writer.kill('SIGTERM')
1961
+ rmSync(fifo, { force: true })
1962
+ },
1963
+ }
1964
+ }
1965
+
1966
+ async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
1454
1967
  const { force = false, guard = true } = opts
1455
- const wt = await findWorktree(id)
1968
+ let wt: { path: string; branch: string | null; rec: SessRec } | null
1969
+ try { wt = await findWorktree(id) }
1970
+ catch (e) { if (e instanceof SessionRecordUnusable) return { ok: false, refused: true, error: e.message }; throw e }
1456
1971
  if (!wt) return { ok: false, error: `no such session ${id}` }
1972
+ // A process that died while validating left an internal candidate behind. This record lock proves no live
1973
+ // resume still owns it. Restore the frozen public original before doing any transport work and require an
1974
+ // explicit retry; stale runtime evidence is never adopted into a fresh launch attempt.
1975
+ if (wt.rec.launchReadinessPending) {
1976
+ writeRecord(restoreLaunchReadinessOriginal(wt.rec))
1977
+ return {
1978
+ ok: false,
1979
+ refused: true,
1980
+ error: `session ${id}: stale launch readiness pending was recovered fail-closed; the exact stopped/offline record was retained. Retry resume.`,
1981
+ }
1982
+ }
1983
+ const preResume = wt.rec
1984
+ // a retired session (its worktree gone) is terminal, not offline: say so in its own words rather than in the
1985
+ // preflight's, since `close` — not a repair — is what it needs.
1986
+ const retired = retirementReason(wt.rec)
1987
+ if (retired) return { ok: false, refused: true, error: retired }
1988
+ // everything else the transport can settle before opening a window: no branch, no launcher. A launch that
1989
+ // cannot succeed must not be attempted, retried, or given a regenerated launch script.
1990
+ const blocked = launchPreflight(wt.rec)
1991
+ if (blocked) return { ok: false, refused: true, error: blocked.message }
1457
1992
  const h = harnessById(wt.rec.harness || defaultHarness.id)
1458
- const lv = liveness(wt.rec, await liveSnapshot()) // FRESH, honest liveness (listener-verified) the guard must not trust a stale board reading
1993
+ // An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
1994
+ // it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
1995
+ // resume can never kill a live leaf merely because the record was hidden.
1996
+ const probeRec = wt.rec.archived ? { ...wt.rec, archived: false, stopped: false } : wt.rec
1997
+ const resumeSnap = await liveSnapshot()
1998
+ const lv = h.runtimeOwnership === 'adapter'
1999
+ ? (resumeSnap.windows.has(id) ? 'online' : 'offline')
2000
+ : liveness(probeRec, resumeSnap) // FRESH, honest liveness (listener-verified)
1459
2001
  if (guard && !force && lv === 'online')
1460
2002
  return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` }
1461
2003
  if (guard && !force && lv === 'unknown')
1462
2004
  return { ok: false, refused: true, error: `session ${id}: the liveness probe failed (the box is likely overloaded) — refusing to relaunch since a live worker can't be ruled out. Retry in a moment, or use force to override.` }
2005
+ const wasArchived = wt.rec.archived
2006
+ if (!wasArchived && wt.rec.adapterRecovery) {
2007
+ const recovery = await h.restoreRuntime?.(wt.rec)
2008
+ if (recovery && !recovery.ok) return { ok: false, refused: true, error: `session ${id}: recovery required before resume — ${recovery.reason}` }
2009
+ writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, stopped: true })
2010
+ wt = await findWorktree(id)
2011
+ if (!wt) return { ok: false, error: `session ${id} disappeared during adapter recovery` }
2012
+ }
2013
+ if (wasArchived && (force || lv === 'offline')) {
2014
+ // Make the durable row visible/offline before any adapter unarchive or launch RPC. Any later failure leaves
2015
+ // a retryable unarchived record rather than archived:true with a newly loaded target thread.
2016
+ const pendingRecovery = wt.rec.adapterRecovery || 'restore-runtime-pending'
2017
+ writeRecord({ ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery })
2018
+ const visible = readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery }
2019
+ const restored = await h.restoreRuntime?.(visible)
2020
+ if (restored && !restored.ok) return { ok: false, refused: true, error: `session ${id}: ${restored.reason}` }
2021
+ writeRecord({ ...(readRecord(id) || visible), adapterRecovery: null, coldProof: null })
2022
+ }
1463
2023
  // proceeding: settle the RESTING lifecycle (a resumed working agent is now idle), then relaunch iff the agent
1464
- // is CONFIRMED offline (or force — the wedged-but-alive escape). `starting`/`unknown` fall through to a no-op.
1465
- writeRecord({ ...wt.rec, status: wt.rec.status === 'active' ? 'idle' : wt.rec.status })
2024
+ // is CONFIRMED offline (or force — the wedged-but-alive escape). Clear the explicit-stop marker only after
2025
+ // launch has accepted the relaunch; a thrown launch leaves the record truthfully stopped. `starting`/`unknown`
2026
+ // fall through to a metadata-only no-op.
2027
+ // Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
2028
+ // starting -> online launch path recreates the same conversation.
2029
+ const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec
2030
+ const resumed: SessRec = { ...current, archived: false, coldProof: null, status: current.status === 'active' ? 'idle' : current.status, stopped: false }
1466
2031
  if (force || lv === 'offline') {
1467
2032
  await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
1468
- await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
1469
- await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
1470
- }
2033
+ const transfer = opts.authorization && opts.ticket && h.sharedRuntimeSpawn
2034
+ ? await delegatedSpawnTransfer(id, opts.ticket)
2035
+ : null
2036
+ try {
2037
+ await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec), transfer?.fifo)
2038
+ if (transfer) await transfer.done
2039
+ } finally { transfer?.close() }
2040
+ let readiness: HarnessLaunchReadinessFence | null = null
2041
+ let readinessError = ''
2042
+ try { readiness = await waitForReady(id, h, resumed) }
2043
+ catch (error) { readinessError = error instanceof Error ? error.message : String(error) }
2044
+ if (!readiness) {
2045
+ const failed = readRecord(id) || current
2046
+ writeRecord({ ...failed, ...preResume, launchReadinessPending: null })
2047
+ return {
2048
+ ok: false,
2049
+ refused: true,
2050
+ error: `session ${id}: launch did not become ready${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
2051
+ }
2052
+ }
2053
+ const latest = readRecord(id) || resumed
2054
+ const candidate: SessRec = {
2055
+ ...latest,
2056
+ archived: false,
2057
+ coldProof: null,
2058
+ status: latest.status === 'active' ? 'idle' : latest.status,
2059
+ stopped: false,
2060
+ launchReadinessPending: launchReadinessPending(preResume),
2061
+ }
2062
+ writeRecord(candidate)
2063
+ let stillReady = false
2064
+ try { stillReady = await readiness.validate(() => {
2065
+ const stored = readRecord(id)
2066
+ return stored ? { ...stored, runtimeDir: runtimeRoot() } : null
2067
+ }) }
2068
+ catch (error) { readinessError = error instanceof Error ? error.message : String(error) }
2069
+ if (!stillReady) {
2070
+ const failed = readRecord(id) || candidate
2071
+ writeRecord(restoreLaunchReadinessOriginal(failed))
2072
+ return {
2073
+ ok: false,
2074
+ refused: true,
2075
+ error: `session ${id}: launch readiness changed across the pending publication${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
2076
+ }
2077
+ }
2078
+ const published = readRecord(id) || candidate
2079
+ writeRecord({ ...published, launchReadinessPending: null })
2080
+ } else writeRecord(resumed)
1471
2081
  return { ok: true }
1472
2082
  }
2083
+ export const resumeSession = (id: string, opts: ResumeOptions = {}) =>
2084
+ runSessionOperation({ op: 'resume', sessionId: id, force: opts.force === true, ...(opts.authorization ? { authorization: opts.authorization } : {}) },
2085
+ (ticket) => withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, { ...opts, ticket }))))
1473
2086
 
1474
2087
  // @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
1475
2088
  // authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
@@ -1479,14 +2092,16 @@ export async function resumeSession(id: string, opts: { force?: boolean; guard?:
1479
2092
  export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
1480
2093
  const id = opts.sessionId || ownSessionId()
1481
2094
  if (!id) return false
1482
- const rec = readRecord(id)
1483
- if (!rec) return false
1484
- writeRecord({
1485
- ...rec, status,
1486
- proposal: status === 'awaiting' ? (opts.proposal ?? 'nothing') : null,
1487
- note: opts.note ?? null,
1488
- })
1489
- return true
2095
+ return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2096
+ const rec = readLiveRecord(id)
2097
+ if (!rec) return false
2098
+ writeRecord({
2099
+ ...rec, status,
2100
+ proposal: status === 'awaiting' ? (opts.proposal ?? 'nothing') : null,
2101
+ note: opts.note ?? null,
2102
+ })
2103
+ return true
2104
+ }))
1490
2105
  }
1491
2106
  export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
1492
2107
  export const markError = (sessionId?: string) => markState('error', { sessionId })
@@ -1495,19 +2110,23 @@ export const markError = (sessionId?: string) => markState('error', { sessionId
1495
2110
  // a zero exit is never routed here, and a declaration that landed before teardown is authoritative.
1496
2111
  export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
1497
2112
  if (exitCode === '0') return false
1498
- const rec = readRecord(sessionId)
1499
- if (!rec || rec.status !== 'active') return false
1500
- const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`
1501
- writeRecord({ ...rec, status: 'error', proposal: null, note: `${harness} turn exited with ${outcome}` })
1502
- return true
2113
+ return runSessionOperationSync({ op: 'lifecycle-transition', sessionId }, () => withRecordLockSync(sessionId, () => {
2114
+ const rec = readLiveRecord(sessionId)
2115
+ if (!rec || rec.status !== 'active') return false
2116
+ const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`
2117
+ writeRecord({ ...rec, status: 'error', proposal: null, note: `${harness} turn exited with ${outcome}` })
2118
+ return true
2119
+ }))
1503
2120
  }
1504
2121
  export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
1505
2122
  const id = sessionId || ownSessionId()
1506
2123
  if (!id || !harnessSessionId) return false
1507
- const rec = readRecord(id)
1508
- if (!rec) return false
1509
- writeRecord({ ...rec, harnessSessionId })
1510
- return true
2124
+ return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2125
+ const rec = readLiveRecord(id)
2126
+ if (!rec) return false
2127
+ writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
2128
+ return true
2129
+ }))
1511
2130
  }
1512
2131
  // @@@ markIdle - the ONE INFERRED state, so (unlike the agent-authored writers above) it carries a strict
1513
2132
  // active-only guard: the Notification(idle_prompt) hook fires it when claude is waiting at its prompt, and it
@@ -1517,10 +2136,12 @@ export function markHarnessSessionId(sessionId: string | undefined, harnessSessi
1517
2136
  export function markIdle(sessionId?: string): boolean {
1518
2137
  const id = sessionId || ownSessionId()
1519
2138
  if (!id) return false
1520
- const rec = readRecord(id)
1521
- if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
1522
- writeRecord({ ...rec, status: 'idle' })
1523
- return true
2139
+ return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2140
+ const rec = readLiveRecord(id)
2141
+ if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
2142
+ writeRecord({ ...rec, status: 'idle' })
2143
+ return true
2144
+ }))
1524
2145
  }
1525
2146
  // @@@ asking has TWO writers, both deterministic (neither guarded active-only): (1) the mark-active
1526
2147
  // PreToolUse hook captures it the instant the agent invokes the AskUserQuestion tool (status=asking,
@@ -1529,9 +2150,13 @@ export function markIdle(sessionId?: string): boolean {
1529
2150
  // way the mark-active path clears it back to active on the next tool / prompt, same as any non-active state.
1530
2151
 
1531
2152
  // @@@ mergeReadiness - the deterministic commit gate the Stop hook enforces before a session may declare
1532
- // done / propose merge. The dogfood ritual lands every change as a COMMIT on the node branch first, so two
1533
- // states block a declaration: (1) any uncommitted working-tree change, or (2) 0 commits ahead of main
1534
- // (nothing committed to merge). Since the global-store refactor, SpexCode writes NO per-session files into
2153
+ // done. The dogfood ritual lands every change as a COMMIT on the node branch first, so an uncommitted
2154
+ // working tree blocks EITHER proposal: the declaration claims the work is committed, and a dirty tree makes
2155
+ // that false. The second condition 0 commits ahead of main is checked ONLY for `merge`, because it is
2156
+ // the only one the claim contradicts: `--propose merge` asserts there is committed work to land, while
2157
+ // `--propose nothing` asserts the opposite ("committed, but I am NOT proposing a merge; paused for the human
2158
+ // to look"), which a lane whose work ALREADY landed states truthfully. Gating `nothing` on ahead-of-main
2159
+ // left such a lane one way through: an empty commit — a lie in git history to satisfy a check about honesty. Since the global-store refactor, SpexCode writes NO per-session files into
1535
2160
  // the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are exclude-
1536
2161
  // hidden materialized artifacts or filter-covered contract blocks ([[residence]]), so
1537
2162
  // neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
@@ -1539,7 +2164,7 @@ export function markIdle(sessionId?: string): boolean {
1539
2164
  // Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
1540
2165
  // can't misdirect repo discovery to the cwd (the same trap git.ts documents). `main` resolves via the shared
1541
2166
  // refs, so `main..HEAD` works from any linked worktree regardless of where main is checked out.
1542
- export function mergeReadiness(): { ready: boolean; reason?: string } {
2167
+ export function mergeReadiness(proposal: 'merge' | 'nothing' = 'merge'): { ready: boolean; reason?: string } {
1543
2168
  let dirty: string[] = []
1544
2169
  try {
1545
2170
  dirty = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean).map(porcelainPath)
@@ -1548,10 +2173,12 @@ export function mergeReadiness(): { ready: boolean; reason?: string } {
1548
2173
  const shown = dirty.slice(0, 8).join(', ') + (dirty.length > 8 ? ', …' : '')
1549
2174
  return { ready: false, reason: `uncommitted changes on your node branch (${shown}) — commit your spec+code first` }
1550
2175
  }
2176
+ // a `nothing` proposal makes no claim about having something to land, so the clean tree is the whole gate.
2177
+ if (proposal === 'nothing') return { ready: true }
1551
2178
  let ahead = 0
1552
2179
  const base = mainBranch()
1553
2180
  try { ahead = Number(git(['rev-list', '--count', `${base}..HEAD`]).trim()) || 0 } catch { ahead = 0 }
1554
- if (ahead === 0) return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge` }
2181
+ if (ahead === 0) return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge (declaring \`done --propose nothing\` needs no commits ahead; use it to pause for the human)` }
1555
2182
  return { ready: true }
1556
2183
  }
1557
2184
 
@@ -1573,9 +2200,17 @@ function porcelainPath(line: string): string {
1573
2200
  // real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
1574
2201
  // the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
1575
2202
  // has that id.
2203
+ // The measured-loss READOUT beside the git/graph gates. It grades nothing: no threshold, no pass/fail, no
2204
+ // block — the manager reads the four mutually exclusive scenario categories [[session-eval]] already folded
2205
+ // and decides. Its phase is part of the fact: a projection that has not been computed yet is NOT a clean
2206
+ // gate, so an absent/loading/updating/failed projection reports that phase and carries no numbers at all
2207
+ // rather than four honest-looking zeros.
2208
+ export type ReviewEvalFacts = { freshPass: number; freshFail: number; needReview: number; blind: number }
2209
+ export type ReviewEvalGate = ({ phase: 'ready' } & ReviewEvalFacts) | { phase: 'unavailable' | 'loading' | 'updating' | 'error' }
1576
2210
  export type ReviewGates = {
1577
2211
  conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
1578
2212
  lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
2213
+ evals: ReviewEvalGate // [[session-eval]]'s already-computed scenario categories
1579
2214
  }
1580
2215
  export type ReviewPayload = {
1581
2216
  id: string; node: string | null; branch: string | null
@@ -1625,6 +2260,22 @@ async function lintGate(): Promise<ReviewGates['lint']> {
1625
2260
  return p
1626
2261
  }
1627
2262
 
2263
+ // @@@ evalGate - the cockpit's measured-loss readout, taken from [[session-eval]]'s EXISTING projection.
2264
+ // This is a cache READ and must stay one: buildSessionEvals() itself calls reviewPayload(), so building the
2265
+ // model from here would recurse. The import is dynamic for the same reason the lint gate's is — the eval
2266
+ // package imports this module, and the cockpit only needs it at call time.
2267
+ async function evalGate(id: string): Promise<ReviewEvalGate> {
2268
+ const { sessionEvalProjection } = await import('../../spec-eval/src/sessioneval.js')
2269
+ const projection = sessionEvalProjection(id)
2270
+ if (!projection) return { phase: 'unavailable' }
2271
+ // only a `ready` projection carries a CURRENT value; last-known is deliberately not reported as current.
2272
+ if (projection.phase !== 'ready' || !projection.value) {
2273
+ return { phase: projection.phase === 'ready' ? 'unavailable' : projection.phase }
2274
+ }
2275
+ const summary = projection.value
2276
+ return { phase: 'ready', freshPass: summary.pass, freshFail: summary.fail, needReview: summary.review, blind: summary.blind }
2277
+ }
2278
+
1628
2279
  // @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
1629
2280
  // (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
1630
2281
  // in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
@@ -1634,12 +2285,13 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1634
2285
  const wt = await findWorktree(id)
1635
2286
  if (!wt) return null
1636
2287
  const base = mainBranch()
1637
- const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
2288
+ const [aheadOut, statusOut, diff, conflictsWithMain, lint, evals] = await Promise.all([
1638
2289
  gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
1639
2290
  gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
1640
2291
  mergeBaseDiff(wt.path, base),
1641
2292
  mergeConflicts(wt.path, base),
1642
2293
  lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
2294
+ evalGate(id), // measured loss — a READ of the existing projection, never a build
1643
2295
  ])
1644
2296
  // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
1645
2297
  // path is genuine work — this is just the total uncommitted count.
@@ -1649,7 +2301,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1649
2301
  label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
1650
2302
  ahead: Number(aheadOut.trim()) || 0,
1651
2303
  dirtyNonRuntime, diff,
1652
- gates: { conflictsWithMain, lint },
2304
+ gates: { conflictsWithMain, lint, evals },
1653
2305
  proposal: { kind: wt.rec.proposal, note: wt.rec.note },
1654
2306
  }
1655
2307
  }
@@ -1658,16 +2310,21 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1658
2310
  // server git script: the agent knows the work, so IT runs the merge, resolves any conflicts, and VERIFIES the
1659
2311
  // outcome — the guarantee lives in that verification, never a server-side gate. This is also the ONE place the
1660
2312
  // merge STYLE is stated (no other mechanism carries it): a --no-ff merge commit `merge <branch>: <reason>`
1661
- // into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree).
2313
+ // into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree)
2314
+ // and that checkout is the fleet's ONE landing door, so the prompt orders the landing to be TRIVIAL by the
2315
+ // time it gets there: sync + resolve in the agent's own worktree, land only when `merge-base --is-ancestor`
2316
+ // says the branch already contains the base, wait (never abort) on someone else's in-progress merge. The
2317
+ // always-on half of that contract is the `atomic-landing` system plugin; this prompt is the per-merge half.
1662
2318
  // After a clean merge the branch is 0 ahead of main, so the agent proposes CLOSE — not merge (the commit gate
1663
2319
  // would block a merge proposal; propose-close is exempt) — and the human confirms the close.
1664
2320
  function mergePrompt(mainPath: string, branch: string, reason: string): string {
1665
2321
  const base = mainBranch()
1666
- return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself.\n\n` +
1667
- `1. Merge from the main checkout with a no-ff merge commit:\n git -C ${mainPath} merge --no-ff -m "merge ${branch}: ${reason}" ${branch}\n` +
1668
- `2. If it conflicts, resolve the conflicts (you know the intent) and complete the merge commit. ` +
1669
- `3. Verify it landed: \`${base}\`'s HEAD must now be the new merge commit and no merge may be left in progress if anything went half-merged, run \`git -C ${mainPath} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
1670
- `4. Once you've verified \`${base}\` advanced cleanly, propose close for the humando NOT close it yourself.`
2322
+ 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` +
2323
+ `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` +
2324
+ `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` +
2325
+ `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. ` +
2326
+ `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. ` +
2327
+ `5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
1671
2328
  }
1672
2329
 
1673
2330
  // @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
@@ -1679,7 +2336,7 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
1679
2336
  // the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
1680
2337
  // CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
1681
2338
  // re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
1682
- export async function mergeSession(id: string): Promise<{ dispatched: boolean; reason?: string }> {
2339
+ async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean; reason?: string }> {
1683
2340
  const wt = await findWorktree(id)
1684
2341
  if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
1685
2342
  const branch = wt.branch, main = mainRoot()
@@ -1693,49 +2350,415 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
1693
2350
  if (!r.ok) return { dispatched: false, reason: r.error }
1694
2351
  return { dispatched: true }
1695
2352
  }
2353
+ export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
2354
+ runSessionOperation({ op: 'merge-dispatch', sessionId: id }, () => mergeSessionUnlocked(id))
2355
+
2356
+ // @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
2357
+ // process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
2358
+ // tmux server and keep running, orphaned, still holding its rendezvous socket (measured: pane gone, server
2359
+ // gone, agent still answering). Close promises ZERO residue including the process tree, so the teardown
2360
+ // escalates on the pid launch registered for exactly this purpose: give the SIGHUP its moment, then SIGTERM,
2361
+ // then SIGKILL, each bounded. This is also what lets the socket sweep run at all — a still-answering listener
2362
+ // is never ours to unlink, so an un-killed agent would otherwise strand its own socket forever.
2363
+ // The escalation is IDENTITY-GUARDED: a recorded pid can have been recycled by an unrelated process, so we
2364
+ // signal only a pid whose argv still names THIS session. Unidentifiable → we signal nothing and let the
2365
+ // adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
2366
+ const AGENT_EXIT_GRACE_MS = 3000
2367
+ type LeafIdentity = { pid: number; startToken: string; ownerNeedle: string }
2368
+ async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, leaf: LeafIdentity): Promise<void> {
2369
+ const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2370
+ if (pid !== leaf.pid)
2371
+ throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
2372
+ if (!Number.isFinite(pid) || pid <= 0) return
2373
+ const startToken = leaf.startToken
2374
+ const alive = (): boolean => {
2375
+ try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
2376
+ }
2377
+ const identityState = (): 'same' | 'gone' | 'changed' => {
2378
+ if (readAgentPid(sessionArtifactPath(id, 'agent.pid')) !== leaf.pid) return 'changed'
2379
+ const current = processStartToken(pid)
2380
+ if (!current) return alive() ? 'changed' : 'gone'
2381
+ return current === startToken ? 'same' : 'changed'
2382
+ }
2383
+ const initialState = identityState()
2384
+ if (initialState === 'gone') return
2385
+ if (initialState === 'changed') throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
2386
+ const gone = async (ms: number): Promise<boolean> => {
2387
+ for (const end = Date.now() + ms; Date.now() < end;) {
2388
+ if (!alive()) return true
2389
+ await new Promise((r) => setTimeout(r, 100))
2390
+ }
2391
+ return !alive()
2392
+ }
2393
+ if (await gone(AGENT_EXIT_GRACE_MS)) return // the pane's SIGHUP took it — the normal path
2394
+ const sameAgentInstance = async (): Promise<boolean> => {
2395
+ if (processStartToken(pid) !== startToken) return false
2396
+ const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
2397
+ return argv.includes(leaf.ownerNeedle) && processStartToken(pid) === startToken
2398
+ }
2399
+ for (const sig of ['SIGTERM', 'SIGKILL'] as const) {
2400
+ await beforeSignal()
2401
+ const state = identityState()
2402
+ if (state === 'gone') return
2403
+ if (state === 'changed') throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed during escalation`)
2404
+ if (!await sameAgentInstance()) throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} no longer proves ownership`)
2405
+ try { process.kill(pid, sig) } catch { return } // vanished between checks
2406
+ if (await gone(sig === 'SIGTERM' ? AGENT_EXIT_GRACE_MS : 1000)) return
2407
+ }
2408
+ }
1696
2409
 
1697
2410
  // @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
1698
- // two: kill the agent's tmux client, drop its boot-window stamp (else a just-launched id lingers in the grace
1699
- // window reading `starting` instead of `offline`), and ask the resolved adapter to sweep its ephemeral runtime
1700
- // transport. Deliberately does NOT drainQueue the caller drains once, after it has settled the worktree.
1701
- async function stopAgentProcess(id: string): Promise<void> {
1702
- const rec = readRecord(id)
2411
+ // two: kill the agent's tmux client, make sure the agent itself actually went with it, drop its boot-window
2412
+ // stamp (else a just-launched id lingers in the grace window reading `starting` instead of `offline`), and ask
2413
+ // the resolved adapter to sweep its ephemeral runtime transport in that order, because the adapter only
2414
+ // removes a transport whose listener is PROVEN dead.
2415
+ // Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
2416
+ async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
2417
+ const harness = harnessById(rec.harness || defaultHarness.id)
2418
+ if (harness.runtimeOwnership === 'adapter') return null
2419
+ // Prove the exact per-session leaf before the first tmux signal. Missing identity is acceptable only for a
2420
+ // record-only/queued runtime; a live leaf without a matching pid/start/argv stays visible.
2421
+ const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2422
+ if (!Number.isFinite(pid) || pid <= 0) {
2423
+ if (rec.stopped || rec.status === 'queued') return null
2424
+ throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`)
2425
+ }
2426
+ const startToken = processStartToken(pid)
2427
+ if (!startToken) {
2428
+ if (rec.stopped) return null
2429
+ throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is not alive or has no start identity`)
2430
+ }
2431
+ const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
2432
+ const ownerNeedle = harness.leafOwnerNeedle?.(rec)
2433
+ if (!ownerNeedle)
2434
+ throw new ResourceConflict(`refusing to stop ${id}: no exact harness identity is registered for leaf PID ${pid}`)
2435
+ if (!argv.includes(ownerNeedle) || processStartToken(pid) !== startToken)
2436
+ throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} does not prove argv ownership`)
2437
+ return { pid, startToken, ownerNeedle }
2438
+ }
2439
+
2440
+ async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false): Promise<void> {
2441
+ // The caller resolves one readable owner before entering this seam. An absent/corrupt record never reaches
2442
+ // tmux, signals, or adapter cleanup: a bare session id is an address, not ownership authority.
2443
+ const assertOwned = () => assertSessionStopSafe(id, rec ? { ...rec, harness: rec.harness } : null)
2444
+ await assertOwned()
2445
+ if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session owner`)
2446
+ const harness = harnessById(rec.harness || defaultHarness.id)
2447
+ const leaf = await assertSessionLeafOwned(id, rec)
2448
+ // Adapter-owned headless sessions may have no live leaf PID, but launch still created an exact tmux session
2449
+ // wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the PID/argv proof, never the
2450
+ // exact tmux teardown.
1703
2451
  await tmuxOk(['kill-session', '-t', id])
2452
+ if (leaf) await killAgentProcess(id, assertOwned, leaf)
1704
2453
  launchedAt.delete(id)
1705
- harnessById(rec?.harness || defaultHarness.id).cleanupRuntime(rec ?? { session: id })
2454
+ await harness.cleanupRuntime(rec)
2455
+ if (requireCold) {
2456
+ const cold = await harness.coldRuntime?.(rec)
2457
+ if (cold && !cold.ok) throw new ResourceConflict(`refusing to archive ${id}: ${cold.reason}`)
2458
+ }
1706
2459
  }
1707
2460
 
1708
2461
  // @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
1709
- // worktree + branch + transcript intact. The session stays on the board, now reading `offline` (no tmux window)
1710
- // whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation (see resumeSession). This is
2462
+ // worktree + branch + transcript intact. The retained record's explicit-stop marker makes liveness `offline`
2463
+ // whatever its lifecycle or adapter probe, so the relaunch panel offers to --resume the SAME conversation. This is
1711
2464
  // "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
1712
2465
  // the freed capacity drains a queued session next (drainQueue).
1713
- export async function stopSession(id: string): Promise<boolean> {
1714
- const wt = await findWorktree(id)
1715
- await stopAgentProcess(id)
1716
- void drainQueue() // a stop frees a slot — start the next queued session if any
2466
+ async function stopSessionUnlocked(id: string): Promise<boolean> {
2467
+ let wt: { path: string; branch: string | null; rec: SessRec } | null
2468
+ try { wt = await findWorktree(id) }
2469
+ catch (e) {
2470
+ if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
2471
+ await stopAgentProcess(id, null)
2472
+ throw e
2473
+ }
2474
+ if (!wt) return false
2475
+ await stopAgentProcess(id, wt.rec)
2476
+ const rec = readRecord(id)
2477
+ if (rec) writeRecord({ ...rec, stopped: true })
2478
+ requestQueueDrain() // a stop frees a slot — start the next queued session if any
1717
2479
  return !!wt
1718
2480
  }
2481
+ export const stopSession = (id: string, opts: { authorization?: Authorization } = {}): Promise<boolean> =>
2482
+ runSessionOperation({ op: 'stop', sessionId: id, ...(opts.authorization ? { authorization: opts.authorization } : {}) },
2483
+ () => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id))))
2484
+
2485
+ // @@@ archiveSession - cold storage ([[archive]]): prove and stop the exact session-owned runtime first, then
2486
+ // write archived:true + stopped:true. The shared adapter root is never torn down. A guard/ownership failure
2487
+ // leaves the record unarchived and visible; no second cleanup primitive or fail-open record write exists.
2488
+ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2489
+ let wt: { path: string; branch: string | null; rec: SessRec } | null
2490
+ try { wt = await findWorktree(id) }
2491
+ catch (e) {
2492
+ if (e instanceof SessionRecordUnusable) throw new ResourceConflict(`refusing to archive ${id}: ${e.message}`)
2493
+ throw e
2494
+ }
2495
+ if (!wt) return false
2496
+ if (!on) throw new ResourceConflict('unarchive is not a record-only transition; use resume to restore the runtime')
2497
+ if (wt.rec.status === 'queued') throw new ResourceConflict(`refusing to archive ${id}: queued sessions have only a prepared launch prompt; resume/startQueued is their lifecycle`)
2498
+ const retired = retirementReason(wt.rec)
2499
+ if (retired) throw new ResourceConflict(`refusing to archive ${id}: ${retired}`)
2500
+ archiving.add(id)
2501
+ try {
2502
+ const h = harnessById(wt.rec.harness || defaultHarness.id)
2503
+ // A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
2504
+ // adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
2505
+ if (wt.rec.archived && hasValidColdProof(wt.rec)) {
2506
+ const proofSnap = await liveSnapshot()
2507
+ if (proofSnap.probeFailed) throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`)
2508
+ const proofLv = h.runtimeOwnership === 'adapter'
2509
+ ? (proofSnap.windows.has(id) ? 'online' : 'offline')
2510
+ : liveness({ ...wt.rec, archived: false, stopped: false }, proofSnap)
2511
+ if (proofLv === 'unknown' || proofLv === 'starting') throw new ResourceConflict(`refusing to re-archive ${id}: session liveness is ${proofLv}; exact cold state is unproven`)
2512
+ if (proofLv === 'offline') {
2513
+ // A deliberately stopped shared control plane is a valid empty resident census. A durable proof plus
2514
+ // an adapter-owned root-absent fact is the only idempotent short-circuit; a live root still has to prove
2515
+ // the thread's archived/non-archived disk collection before we can claim it is already cold.
2516
+ const rootAbsent = await Promise.all((h.sharedRuntimes?.(runtimeRoot()) ?? []).map(async (descriptor) => {
2517
+ if (!descriptor.residency) return false
2518
+ const state = await descriptor.residency()
2519
+ return state.healthy && state.rootAbsent === true && state.referenceIds.length === 0
2520
+ })).then((states) => states.some(Boolean))
2521
+ if (rootAbsent) return true
2522
+ const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true })
2523
+ if (!pre || pre.ok) {
2524
+ const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true })
2525
+ if (!cold || cold.ok) return true
2526
+ }
2527
+ }
2528
+ }
2529
+ // Legacy/respawned archives are made visible before repair. Any refusal below therefore leaves an unarchived
2530
+ // row instead of relying on a hidden bit while a runtime proof is missing.
2531
+ if (wt.rec.archived) {
2532
+ writeRecord({ ...wt.rec, archived: false, coldProof: null })
2533
+ wt = await findWorktree(id)
2534
+ if (!wt) return false
2535
+ }
2536
+
2537
+ const snap = await liveSnapshot()
2538
+ if (snap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`)
2539
+ const lv = h.runtimeOwnership === 'adapter'
2540
+ ? 'offline'
2541
+ : liveness({ ...wt.rec, archived: false, stopped: false }, snap)
2542
+ if (lv === 'unknown' || lv === 'starting')
2543
+ throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
2544
+ // The adapter guard runs BEFORE any tmux/process signal. Active/unknown Codex turns and owned descendants
2545
+ // refuse here, leaving the leaf/shared root/record untouched; coldRuntime is the commit cleanup after this.
2546
+ const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
2547
+ if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
2548
+ // Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
2549
+ // teardown seam with the explicit stopped marker so cleanupRuntime gets its ownership check and no second
2550
+ // cleanup primitive is invented.
2551
+ let coldCommitted = false
2552
+ let coldAttempted = false
2553
+ try {
2554
+ coldAttempted = true
2555
+ await stopAgentProcess(id, { ...wt.rec, archived: false, stopped: lv === 'offline' }, true)
2556
+ coldCommitted = true
2557
+ const latest = readRecord(id)
2558
+ if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
2559
+ const finalSnap = await liveSnapshot()
2560
+ if (finalSnap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: final liveness probe failed; the leaf may still be live`)
2561
+ const finalLv = h.runtimeOwnership === 'adapter'
2562
+ ? (finalSnap.windows.has(id) ? 'online' : 'offline')
2563
+ : liveness({ ...latest, archived: false, stopped: false }, finalSnap)
2564
+ if (finalLv === 'unknown' || finalLv === 'starting' || finalLv === 'online')
2565
+ throw new ResourceConflict(`refusing to archive ${id}: leaf became ${finalLv} before filing`)
2566
+ writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest) })
2567
+ } catch (error) {
2568
+ if (coldCommitted) {
2569
+ const restored = await h.restoreRuntime?.(wt.rec)
2570
+ if (restored && !restored.ok) {
2571
+ const current = readRecord(id)
2572
+ if (current) writeRecord({ ...current, archived: false, stopped: true, coldProof: null, adapterRecovery: `restore-runtime:${restored.reason}` })
2573
+ console.error(`spex: archive compensation for ${id} failed: ${restored.reason}`)
2574
+ }
2575
+ } else if (coldAttempted && error instanceof Error && /compensation failed|state is unknown|reconciliation failed/i.test(error.message)) {
2576
+ const current = readRecord(id)
2577
+ if (current) writeRecord({ ...current, archived: false, stopped: true, coldProof: coldProofFor(current), adapterRecovery: `restore-runtime:${error.message}` })
2578
+ }
2579
+ throw error
2580
+ }
2581
+ requestQueueDrain()
2582
+ return true
2583
+ } finally { archiving.delete(id) }
2584
+ }
2585
+ export const archiveSession = (id: string, on = true): Promise<boolean> => {
2586
+ if (!on) return archiveSessionUnarchive(id)
2587
+ return runSessionOperation({ op: 'archive', sessionId: id },
2588
+ () => withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on))))
2589
+ }
2590
+ async function archiveSessionUnarchive(id: string): Promise<boolean> {
2591
+ const wt = await findWorktree(id)
2592
+ if (!wt) return false
2593
+ if (!wt.rec.archived) return true
2594
+ const resumed = await resumeSession(id)
2595
+ if (!resumed.ok) throw new ResourceConflict(resumed.error || `refusing to resume ${id}`)
2596
+ return true
2597
+ }
2598
+
2599
+ // @@@ cold retirement - archive already returned the target's runtime, so closing a proven-cold row must not
2600
+ // re-enter the live stop guard and make unrelated shared-root references prove ownership again. Verify only
2601
+ // that the target-bound cold proof is still current and that no target PID/window/socket/thread has reappeared.
2602
+ // This is read-only: no signal, adapter mutation, or shared-root cleanup belongs on the cold path.
2603
+ async function assertColdRetirementSafe(id: string, rec: SessRec): Promise<void> {
2604
+ if (!rec.archived || !rec.stopped || !hasValidColdProof(rec))
2605
+ throw new ResourceConflict(`refusing to close archived session ${id}: target-bound cold witness is missing or stale`)
2606
+ if (rec.adapterRecovery)
2607
+ throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`)
2608
+
2609
+ const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
2610
+ if (snap.probeFailed) throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`)
2611
+ if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`)
2612
+ if (socket === 'live') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`)
2613
+ if (socket === 'unproven') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`)
2614
+ const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
2615
+ if (Number.isFinite(pid) && pid > 0 && processStartToken(pid))
2616
+ throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${pid} is live or recycled; ownership is ambiguous`)
2617
+
2618
+ const harness = harnessById(rec.harness || defaultHarness.id)
2619
+ if (harness.coldRetirementPreflight) {
2620
+ const proof = await harness.coldRetirementPreflight(rec)
2621
+ if (!proof.ok) throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`)
2622
+ return
2623
+ }
2624
+ const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? []
2625
+ let everySharedRootAbsent = descriptors.length > 0
2626
+ for (const descriptor of descriptors) {
2627
+ const resident: { healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean } = descriptor.residency
2628
+ ? await descriptor.residency()
2629
+ : await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
2630
+ if (!resident.healthy)
2631
+ throw new ResourceConflict(`refusing to close archived session ${id}: ${resident.error || `${descriptor.label} resident census is unhealthy`}`)
2632
+ if (rec.harnessSessionId && resident.referenceIds.includes(rec.harnessSessionId))
2633
+ throw new ResourceConflict(`refusing to close archived session ${id}: target adapter thread ${rec.harnessSessionId} is loaded`)
2634
+ everySharedRootAbsent = everySharedRootAbsent && resident.rootAbsent === true
2635
+ }
2636
+ if (harness.coldPreflight && !everySharedRootAbsent) {
2637
+ const proof = await harness.coldPreflight(rec)
2638
+ if (!proof.ok) throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`)
2639
+ if (!proof.alreadyCold)
2640
+ throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`)
2641
+ }
2642
+ }
2643
+
2644
+ // A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
2645
+ // this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
2646
+ // No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
2647
+ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string, branch: string | null): Promise<void> {
2648
+ if (rec.status !== 'queued' || rec.harnessSessionId)
2649
+ throw new ResourceConflict(`refusing to close queued session ${id}: the record has a target thread or is no longer queued`)
2650
+ if (rec.adapterRecovery || launching.has(id))
2651
+ throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`)
2652
+
2653
+ const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
2654
+ if (snap.probeFailed) throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`)
2655
+ if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`)
2656
+ if (socket === 'live') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`)
2657
+ if (socket === 'unproven') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous state is ambiguous`)
2658
+ const pidPath = sessionArtifactPath(id, 'agent.pid')
2659
+ if (existsSync(pidPath)) {
2660
+ const pid = readAgentPid(pidPath)
2661
+ throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`)
2662
+ }
2663
+
2664
+ if (existsSync(path)) {
2665
+ const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all'])
2666
+ if (!status.ok) throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree status is unreadable`)
2667
+ if (status.stdout.trim()) throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree has dirty work`)
2668
+ }
2669
+ if (branch) {
2670
+ const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`])
2671
+ if (resolved.ok) {
2672
+ const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`])
2673
+ if (!count.ok) throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch ancestry is unreadable`)
2674
+ const ahead = Number(count.stdout.trim())
2675
+ if (!Number.isFinite(ahead) || ahead !== 0)
2676
+ throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`)
2677
+ } else if (resolved.failure !== 'exit') {
2678
+ throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch identity is unreadable`)
2679
+ } else if (existsSync(path)) {
2680
+ throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree exists but branch ${branch} is missing`)
2681
+ }
2682
+ }
2683
+ }
1719
2684
 
1720
- // @@@ closeSession - the REMOVAL (human-confirmed): stop's soft kill PLUS removing the worktree + branch AND
1721
- // the session's whole global-store record dir the work is gone, not just stopped. Same stop primitive as
1722
- // stopSession (no duplicate kill path), then the git worktree/branch teardown that stop deliberately skips,
1723
- // then the store sweep (stop KEEPS the record so the session stays on the board offline; close discards it).
2685
+ // @@@ closeSession - the REMOVAL (human-confirmed): a live row uses stop's exact kill, while a proven-cold
2686
+ // archive or never-launched queue uses its target-only read proof above. All then remove the worktree + branch and the session's whole
2687
+ // global-store record dir the work is gone, not just stopped. The git/store teardown remains one path.
1724
2688
  // The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
1725
2689
  // so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
1726
- export async function closeSession(id: string): Promise<boolean> {
1727
- const wt = await findWorktree(id)
1728
- await stopAgentProcess(id)
1729
- if (wt) {
1730
- let slot: string | null = null
1731
- try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
1732
- await gitA(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
1733
- if (wt.branch) await gitA(['-C', mainRoot(), 'branch', '-D', wt.branch])
1734
- if (slot) { try { rmSync(slot, { recursive: true, force: true }) } catch { /* best-effort GC */ } }
1735
- }
1736
- try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* best-effort sweep of the global record */ }
1737
- void drainQueue() // a close frees a slot start the next queued session if any
1738
- return !!wt
2690
+ // A corrupt record proves no adapter, leaf, worktree, or branch owner. Close may copy those bytes to the
2691
+ // control-plane quarantine, but then fails before this teardown seam and names every residue it preserved.
2692
+ async function closeSessionUnlocked(id: string): Promise<boolean> {
2693
+ let wt: { path: string; branch: string | null; rec: SessRec } | null = null
2694
+ try { wt = await findWorktree(id) }
2695
+ catch (e) {
2696
+ if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
2697
+ const quarantined = quarantineRecord(id)
2698
+ const runtime = sessionStoreDir(id)
2699
+ const evidence = quarantined
2700
+ ? `Original bytes were copied to ${quarantined}`
2701
+ : `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`
2702
+ let guard = 'no readable session record proves the adapter or leaf owner'
2703
+ try { await stopAgentProcess(id, null) }
2704
+ catch (error) { guard = error instanceof Error ? error.message : String(error) }
2705
+ throw new SessionRecordUnusable('corrupt', id,
2706
+ `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.`)
2707
+ }
2708
+ if (!wt) return false
2709
+ if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
2710
+ else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
2711
+ else await stopAgentProcess(id, wt.rec)
2712
+ let slot: string | null = null
2713
+ try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
2714
+ // a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
2715
+ if (existsSync(wt.path)) {
2716
+ const removed = await gitTry(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
2717
+ if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`)
2718
+ if (existsSync(wt.path)) throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`)
2719
+ }
2720
+ if (wt.branch) {
2721
+ const branchRef = `refs/heads/${wt.branch}`
2722
+ const present = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', '--quiet', branchRef])
2723
+ if (present.ok) {
2724
+ const removed = await gitTry(['-C', mainRoot(), 'branch', '-D', wt.branch])
2725
+ if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`)
2726
+ const remaining = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', '--quiet', branchRef])
2727
+ if (remaining.ok || remaining.failure !== 'exit') throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`)
2728
+ } else if (present.failure !== 'exit') {
2729
+ throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`)
2730
+ }
2731
+ }
2732
+ if (slot) { try { rmSync(slot, { recursive: true, force: true }) } catch { /* best-effort GC */ } }
2733
+ try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
2734
+ catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
2735
+ if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
2736
+ requestQueueDrain() // a close frees a slot — start the next queued session if any
2737
+ return true
2738
+ }
2739
+ export const closeSession = (id: string): Promise<boolean> =>
2740
+ runSessionOperation({ op: 'close', sessionId: id },
2741
+ () => withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id))))
2742
+
2743
+ // @@@ quarantine - closing sweeps the session's whole store dir, so an UNREADABLE record would take the only
2744
+ // evidence of what corrupted it with it. Copy those bytes to the per-project `corrupt/` shelf first, named by
2745
+ // session id and close time. Only unreadable records are shelved (a healthy one's contents are already known
2746
+ // and reproducible); the shelf is never read by the product, it is there for the human who asks "what broke?".
2747
+ function quarantineRecord(id: string): string | null {
2748
+ let entry
2749
+ try { entry = readRecordEntry(id) } catch { return null } // unreadable for another reason (permissions) — leave it
2750
+ if (entry.kind !== 'corrupt') return null
2751
+ try {
2752
+ const shelf = join(runtimeRoot(), 'corrupt')
2753
+ mkdirSync(shelf, { recursive: true })
2754
+ const dest = join(shelf, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`)
2755
+ writeFileSync(dest, readFileSync(entry.path))
2756
+ console.error(`spex: session ${id.slice(0, 8)} had an unreadable record; its original bytes are preserved at ${dest}`)
2757
+ return dest
2758
+ } catch (e) {
2759
+ console.error(`spex: could not quarantine the unreadable record for ${id}: ${e instanceof Error ? e.message : e}`)
2760
+ return null
2761
+ }
1739
2762
  }
1740
2763
 
1741
2764
  // @@@ captureSessionResult - the session's live pane as a one-shot snapshot (output), the server side of
@@ -1749,7 +2772,7 @@ export async function closeSession(id: string): Promise<boolean> {
1749
2772
  export type CaptureResult = { ok: true; pane: string } | { ok: false; reason: 'unknown' | 'offline' | 'capture-failed' }
1750
2773
  export async function captureSessionResult(id: string): Promise<CaptureResult> {
1751
2774
  if (!(await alive(id))) {
1752
- const known = (await listSessions()).some((s) => s.id === id)
2775
+ const known = (await listSessions(true)).some((s) => s.id === id)
1753
2776
  return { ok: false, reason: known ? 'offline' : 'unknown' }
1754
2777
  }
1755
2778
  try { return { ok: true, pane: await tmux(['capture-pane', '-e', '-p', '-t', id]) } }
@@ -1768,9 +2791,11 @@ export async function captureSessionResult(id: string): Promise<CaptureResult> {
1768
2791
  export const STATUS_GLYPH: Record<DisplayStatus, string> = {
1769
2792
  working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
1770
2793
  'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
2794
+ corrupt: '\u26a0', retired: '\u2691',
1771
2795
  }
1772
2796
  const ANSI: Record<DisplayStatus, string> = {
1773
2797
  working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90', unknown: '93',
2798
+ corrupt: '31', retired: '90',
1774
2799
  }
1775
2800
 
1776
2801
  // @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
@@ -1913,11 +2938,13 @@ export function launchEvent(s: Session): string {
1913
2938
  const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
1914
2939
  return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
1915
2940
  }
1916
- // @@@ source - the session board the poll reads. The CLI passes the BACKEND CLIENT (client.ts
1917
- // clientListSessions), so `spex session watch` streams whatever backend SPEXCODE_API_URL points at including a
1918
- // REMOTE machine's. It is REQUIRED (no local default): a forgotten source must be a compile error, never a
1919
- // silent in-process read of the wrong (local) board the exact false-green the 2-machine test guards.
1920
- export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number; onObserved?: (status: DisplayStatus, previous: DisplayStatus | null) => void } }
2941
+ // @@@ source/presenceSource - event population and existence truth are deliberately separate projections. The
2942
+ // broad CLI watch passes the default active-only source so it never enumerates the archive shelf, plus an
2943
+ // all-record `presenceSource` so a hidden archive is not mistaken for a removed worktree. An explicit selector
2944
+ // passes the all-record source for BOTH, allowing that one target's offline archive/resume transitions through.
2945
+ // Both are backend clients (client.ts), so `spex session watch` monitors the machine named by
2946
+ // SPEXCODE_API_URL; a forgotten source must be a compile error, never a silent local-board fallback.
2947
+ export type WatchOpts = { source: () => Promise<Session[]>; presenceSource?: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number; onObserved?: (status: DisplayStatus, previous: DisplayStatus | null) => void } }
1921
2948
  // @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex session wait` runs on); a
1922
2949
  // plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
1923
2950
  // "block for a worker's NEXT transition, then exit" that is GUARANTEED to return. Bounded mode is
@@ -1937,7 +2964,7 @@ export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]
1937
2964
  // supervisor must never be able to read a transport failure as a session state (issue #40).
1938
2965
  export type WatchOutcome = { reached: DisplayStatus; path: DisplayStatus[] } | { timedOut: true; path: DisplayStatus[] } | { gone: true; path: DisplayStatus[] } | { backendDown: string; kind: 'unreachable' | 'http' }
1939
2966
  export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
1940
- const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
2967
+ const { source, presenceSource = source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
1941
2968
  const tag = as ? `[${as}] ` : ''
1942
2969
  const prev = new Map<string, DisplayStatus>()
1943
2970
  const paths = new Map<string, DisplayStatus[]>() // bounded mode: every status observed per target, arrival first
@@ -1950,14 +2977,19 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1950
2977
  let downMsg: string | null = null // set while the backend is unreachable, cleared on a good poll; the deadline reports it
1951
2978
  for (;;) {
1952
2979
  try {
1953
- // EXISTENCE is the selector-matched board across ALL statuses listSessions now lists every worktree
1954
- // that exists (a transient detail-read failure degrades a row, never drops it see guardWorktree), so
1955
- // membership here IS the worktree's existence. The `statuses` filter governs only which TRANSITIONS we
1956
- // emit, never whether a session is present — using it for presence would read a status change out of the
1957
- // filtered set as a (false) removal.
1958
- const all = selectSessions(await source(), selectors)
2980
+ // EVENTS come from the caller's chosen population (broad watch uses active-only; an explicit selector
2981
+ // uses history). EXISTENCE always comes from the all-record presence source, so a cold archive can never
2982
+ // look removed merely because the default event population hides it. The `statuses` filter governs only
2983
+ // which transitions we emit, never whether a session is present.
2984
+ const eventRows = await source()
2985
+ // Explicit selector wait passes the same history function for both roles. Reuse that one snapshot so a
2986
+ // single poll cannot combine two HTTP instants (or pay for the same request twice); broad watch supplies
2987
+ // distinct function references because it genuinely needs active events plus all-record presence.
2988
+ const presenceRows = presenceSource === source ? eventRows : await presenceSource()
2989
+ const all = selectSessions(eventRows, selectors)
2990
+ const presence = selectSessions(presenceRows, selectors)
1959
2991
  warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
1960
- const ids = new Set(all.map((s) => s.id))
2992
+ const ids = new Set(presence.map((s) => s.id))
1961
2993
  const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
1962
2994
  let edge: { status: DisplayStatus; path: DisplayStatus[] } | null = null
1963
2995
  for (const s of all) {
@@ -1993,7 +3025,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1993
3025
  // before we hand control back.
1994
3026
  if (until) {
1995
3027
  if (edge) return { reached: edge.status, path: edge.path }
1996
- if (!all.length) return { gone: true, path: anyPath() }
3028
+ if (!presence.length) return { gone: true, path: anyPath() }
1997
3029
  }
1998
3030
  } catch (e) {
1999
3031
  // a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
@@ -2027,7 +3059,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
2027
3059
  // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
2028
3060
  // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
2029
3061
  // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
2030
- export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
3062
+ async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
2031
3063
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
2032
3064
  const rec = readRecord(id)
2033
3065
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
@@ -2051,15 +3083,22 @@ export async function sendText(id: string, text: string, from?: string, opts: {
2051
3083
  if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
2052
3084
  return r
2053
3085
  }
3086
+ export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
3087
+ // The record lock spans the delivery RPC. Archive preflight and leaf teardown cannot race a product turn
3088
+ // start/steer/input from another CLI process and then discover it only after killing the pane.
3089
+ return runSessionOperation({ op: 'send', sessionId: id }, () => withRecordLock(id, () => sendTextUnlocked(id, text, from, opts)))
3090
+ }
2054
3091
 
2055
3092
  // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
2056
3093
  // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
2057
3094
  export async function interruptSession(id: string): Promise<DispatchResult> {
2058
- const rec = readRecord(id)
2059
- if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
2060
- const h = harnessById(rec.harness || defaultHarness.id)
2061
- if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
2062
- return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
3095
+ return runSessionOperation({ op: 'interrupt', sessionId: id }, () => withRecordLock(id, async () => {
3096
+ const rec = readRecord(id)
3097
+ if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
3098
+ const h = harnessById(rec.harness || defaultHarness.id)
3099
+ if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
3100
+ return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
3101
+ }))
2063
3102
  }
2064
3103
 
2065
3104
  // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
@@ -2115,13 +3154,15 @@ function rawKeyArgs(id: string, key: string): string[] | null {
2115
3154
  // (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
2116
3155
  // An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
2117
3156
  export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
2118
- const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
2119
- if (list.length === 0 || !(await alive(id))) return false
2120
- let sent = false
2121
- for (const k of list) {
2122
- const args = rawKeyArgs(id, k)
2123
- if (!args) continue
2124
- await tmux(args); sent = true
2125
- }
2126
- return sent
3157
+ return runSessionOperation({ op: 'raw-key-input', sessionId: id }, () => withRecordLock(id, async () => {
3158
+ const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
3159
+ if (list.length === 0 || !(await alive(id))) return false
3160
+ let sent = false
3161
+ for (const k of list) {
3162
+ const args = rawKeyArgs(id, k)
3163
+ if (!args) continue
3164
+ await tmux(args); sent = true
3165
+ }
3166
+ return sent
3167
+ }))
2127
3168
  }