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,5 +1,5 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
3
  import { homedir, tmpdir } from 'node:os'
4
4
  import { createHash, randomBytes } from 'node:crypto'
5
5
  import { createConnection, type Socket } from 'node:net'
@@ -13,8 +13,10 @@ import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadle
13
13
  import { codexHeadlessLaunchCommand } from './codex-headless.js'
14
14
  import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
15
15
  import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
16
- import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
16
+ import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
17
17
  import { git } from './git.js'
18
+ import { shQuote } from './sh.js'
19
+ import { processStartToken, processTopology } from './process-identity.js'
18
20
 
19
21
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
20
22
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -29,7 +31,12 @@ import { git } from './git.js'
29
31
  // (materialize writes every harness's artifacts).
30
32
 
31
33
  export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
32
- export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
34
+ export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null; stopped?: boolean; archived?: boolean }
35
+ export type HarnessLaunchReadyRecord = HarnessLivenessRecord & { governed?: boolean; runtimeDir: string }
36
+ export type HarnessLaunchReadinessFence = {
37
+ readonly proof: Readonly<Record<string, unknown>>
38
+ validate(current: () => HarnessLaunchReadyRecord | null): Promise<boolean>
39
+ }
33
40
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
34
41
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
35
42
  // one whole-box pid→(ppid, comm) table (a single `ps` spawn).
@@ -42,6 +49,81 @@ export type HarnessLivenessRecord = { session: string; harnessSessionId?: string
42
49
  // tree-walk, so a box with no codex — or all pid-registered launches — never pays for it.
43
50
  export type ProcTable = Map<number, { ppid: number; comm: string }>
44
51
  export type PaneProbe = { panePid?: number; procs?: ProcTable; pidAlive?: boolean }
52
+ export type SharedRuntimeDescriptor = {
53
+ key: string
54
+ label: string
55
+ pidFile: string
56
+ isolationFile: string
57
+ // Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
58
+ // per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
59
+ residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
60
+ // Lifecycle mutation guard is deliberately narrower than the full resource projection: census every loaded
61
+ // ID, but read only the exact governed target when it is loaded, plus both target descendant collections.
62
+ mutationGuard?: (targetReferenceId: string) => Promise<SharedRuntimeMutationGuard>
63
+ probe(): Promise<SharedRuntimeProbe>
64
+ }
65
+ export type SharedRuntimeMutationGuard = {
66
+ healthy: boolean
67
+ referenceIds: string[]
68
+ targetTurnPresence: 'none' | 'idle' | 'active' | 'unknown'
69
+ descendantIds: string[]
70
+ error?: string
71
+ }
72
+ export type SharedRuntimeProbe = {
73
+ healthy: boolean
74
+ references: Array<{
75
+ referenceId: string
76
+ turnPresence: 'idle' | 'active' | 'unknown'
77
+ turnId?: string
78
+ }>
79
+ error?: string
80
+ }
81
+
82
+ export type AdapterLoadedReferenceState = {
83
+ healthy: boolean
84
+ loaded: boolean
85
+ error?: string
86
+ }
87
+
88
+ // One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
89
+ // once per call, then its result is joined to every record that names that adapter/thread. Product readers must
90
+ // not turn this into one RPC per row: a loaded thread can be externally reloaded after its cold proof was filed.
91
+ export async function adapterLoadedReferenceState(
92
+ records: readonly (HarnessLivenessRecord & { harness?: string })[],
93
+ runtimeDir = runtimeRoot(),
94
+ ): Promise<Map<string, AdapterLoadedReferenceState>> {
95
+ const descriptors = new Map<string, SharedRuntimeDescriptor>()
96
+ const recordKeys = new Map<string, string[]>()
97
+ for (const rec of records) {
98
+ if (!rec.harnessSessionId) continue
99
+ const keys = (harnessById(rec.harness || defaultHarness.id).sharedRuntimes?.(runtimeDir) ?? []).map((descriptor) => {
100
+ descriptors.set(descriptor.key, descriptor)
101
+ return descriptor.key
102
+ })
103
+ recordKeys.set(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`, keys)
104
+ }
105
+ const probes = await Promise.all([...descriptors.entries()].map(async ([key, descriptor]) => {
106
+ try {
107
+ const result = descriptor.residency
108
+ ? await descriptor.residency()
109
+ : await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
110
+ return [key, result] as const
111
+ }
112
+ catch (error) { return [key, { healthy: false, referenceIds: [] as string[], error: (error as Error).message }] as const }
113
+ }))
114
+ const byKey = new Map(probes)
115
+ const result = new Map<string, AdapterLoadedReferenceState>()
116
+ for (const [recordKey, keys] of recordKeys) {
117
+ const refs = keys.map((key) => byKey.get(key)!).filter(Boolean)
118
+ if (!refs.length) continue
119
+ const unhealthy = refs.find((probe) => !probe.healthy)
120
+ const threadId = recordKey.slice(recordKey.indexOf(':') + 1)
121
+ result.set(recordKey, unhealthy
122
+ ? { healthy: false, loaded: false, error: unhealthy.error || 'adapter resident-reference census is unhealthy' }
123
+ : { healthy: true, loaded: refs.some((probe) => probe.referenceIds.includes(threadId)) })
124
+ }
125
+ return result
126
+ }
45
127
 
46
128
  export interface Harness {
47
129
  readonly id: HarnessId
@@ -51,9 +133,18 @@ export interface Harness {
51
133
  // whether the launch command intentionally exits after its first turn instead of owning a resident process.
52
134
  // One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
53
135
  readonly launchOneShot?: boolean
54
- // whether this harness persists a native event stream that the console may expose as an optional
55
- // full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
56
- readonly messageStream: boolean
136
+ // Adapter-owned runtime shape: headless controllers/shared threads have no interactive TUI leaf to signal.
137
+ readonly runtimeOwnership?: 'leaf' | 'adapter'
138
+ // This launch command may create its project-shared control plane through internal shared-runtime-spawn.
139
+ readonly sharedRuntimeSpawn?: boolean
140
+ // @@@ fatalLaunchOutput - extended regexes matching THIS harness's own report of a launch failure that
141
+ // RUNNING IT AGAIN CANNOT FIX: a conversation that does not exist, a rejected credential, a broken config.
142
+ // A launcher that exits within the boot window tells us only that it exited fast, which is why the transport
143
+ // retries — but when the harness itself named a settled cause, retrying spends a certain failure two more
144
+ // times and buries the one line that explains it. So the transport asks the ADAPTER, and the adapter is the
145
+ // only place a harness's wording is ever matched: product code consumes the verdict (retry / fatal), never
146
+ // the text. A harness that declares none keeps the plain bounded retry.
147
+ readonly fatalLaunchOutput?: readonly string[]
57
148
  // the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
58
149
  // Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
59
150
  // idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
@@ -89,6 +180,9 @@ export interface Harness {
89
180
  sessionIdArg(id: string): string
90
181
  // the env var the agent's OWN process carries so its `spex …` calls know their session id.
91
182
  readonly sessionEnvVar: string
183
+ // transport bootstrap variables scoped to this launch. Rendezvous adapters own their daemon mode + socket;
184
+ // product launch code only composes these with generic session/home env.
185
+ launchEnv(id: string): string[]
92
186
 
93
187
  // --- materialize: shim + contract + trust ([[harness-delivery]]) ---
94
188
  // the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
@@ -148,6 +242,17 @@ export interface Harness {
148
242
  // to the shell). A missing probe (tmux/ps couldn't report) is not-live. The 'starting' boot
149
243
  // grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
150
244
  liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
245
+ // A completed launch command is only transport acceptance. An adapter with stronger runtime ownership may
246
+ // keep the caller waiting until the launched conversation is genuinely addressable. The lazy record source
247
+ // lets a one-shot launch publish its native id while readiness is pending. The returned adapter-owned fence
248
+ // names the facts that established readiness and revalidates those SAME facts across the caller's record
249
+ // commit. Null at the deadline is a launch failure; adapters without this seam retain the generic bounded
250
+ // liveness fence.
251
+ launchReady?(current: () => HarnessLaunchReadyRecord | null, deadline: number): Promise<HarnessLaunchReadinessFence | null>
252
+ // Exact leaf ownership evidence consumed by lifecycle teardown. The adapter returns the one argv identity
253
+ // token it registered for this record (session id, harness thread/generation, or null when unprovable);
254
+ // product lifecycle code never branches on harness names to invent this identity.
255
+ leafOwnerNeedle?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
151
256
  // deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
152
257
  // control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
153
258
  // before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
@@ -163,7 +268,22 @@ export interface Harness {
163
268
  // Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
164
269
  // launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
165
270
  // per-session socket. Product teardown calls only this adapter method.
166
- cleanupRuntime(rec: HarnessLivenessRecord): void
271
+ // Async because removal is CONDITIONAL on proof: a transport is only ours to remove once its listener is
272
+ // proven dead (see unlinkSocks), and that proof is a connect probe.
273
+ cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
274
+ // Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
275
+ // unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
276
+ coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold?: boolean } | { ok: false; reason: string }>
277
+ // A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
278
+ // this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
279
+ coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
280
+ // Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
281
+ // reference or return a loud reason; adapters without such a resident reference return {ok:true}.
282
+ coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
283
+ restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
284
+ // Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
285
+ // references and protect a sibling-owned control plane without learning harness command names.
286
+ sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
167
287
  // the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
168
288
  // socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
169
289
  // the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
@@ -214,12 +334,36 @@ export type HarnessArtifacts = { skills: readonly string[]; agents: readonly str
214
334
  // sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
215
335
  // that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
216
336
  // `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
217
- // and Enters can't be corrupted the way `tmux send-keys` was. The path is uniquely derived from the session id,
218
- // so we only ever address OUR OWN sockets (HARD ethics rule: never touch a session outside this product). It
219
- // lives in tmpdir tied to the claude process, so no extra lifecycle. liveness CONNECTS to it (a live LISTENER,
220
- // not merely the file — see rendezvousListening); deliver writes to it. Exported because sessions.ts builds the
221
- // launch env var from it and best-effort sweeps it on close but the liveness/delivery USE is the adapter's, below.
222
- export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
337
+ // and Enters can't be corrupted the way `tmux send-keys` was. It lives in tmpdir tied to the claude process, so
338
+ // no extra lifecycle. liveness CONNECTS to it (a live LISTENER, not merely the file see rendezvousListening);
339
+ // deliver writes to it.
340
+ //
341
+ // The path is a LAUNCH-TIME FACT, recorded not a formula every consumer re-derives. The id alone was not
342
+ // enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
343
+ // worlds on one box (a fixture, a migration, a copied record) can hold the same session id — and while the
344
+ // path ignored that scoping, they SHARED this socket. That is how an isolated teardown reached out and
345
+ // stranded a live production agent, and delivery would have crossed the same way. So the path a launch hands
346
+ // its agent is derived from the runtime the session belongs to (`runtimeRoot()` — the same identity that
347
+ // scopes its store) and STAMPED beside the record, exactly like `agent.pid`: a launch-time fact, readable by
348
+ // everyone who needs to reach that agent afterwards. Recording it (rather than re-deriving) also means the
349
+ // derivation can change again without stranding anything already running.
350
+ // `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
351
+ // the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
352
+ export const legacyRvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
353
+ export const scopedRvSock = (id: string, dir = runtimeRoot()) =>
354
+ join(tmpdir(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
355
+ const rvStamp = (id: string) => sessionArtifactPath(id, 'rv.path')
356
+ export const rvSock = (id: string): string => {
357
+ try { return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id) } catch { return legacyRvSock(id) }
358
+ }
359
+ // launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
360
+ // liveness probe, delivery, teardown) reads the one path the agent actually bound.
361
+ export function stampRvSock(id: string, dir = runtimeRoot()): string {
362
+ const path = scopedRvSock(id, dir)
363
+ mkdirSync(dirname(rvStamp(id)), { recursive: true })
364
+ writeFileSync(rvStamp(id), path)
365
+ return path
366
+ }
223
367
 
224
368
  // @@@ rendezvousListening - the LISTENER check that IS claude's liveness truth ([[state]], [[harness-adapter]]).
225
369
  // A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
@@ -237,7 +381,7 @@ export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
237
381
  // wedged/thrashed path. Never throws.
238
382
  export type ListenerProbe = 'live' | 'dead' | 'unproven'
239
383
  const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT'])
240
- export function rendezvousListening(id: string, timeoutMs = 800): Promise<ListenerProbe> {
384
+ export function listenerAt(path: string, timeoutMs = 800): Promise<ListenerProbe> {
241
385
  return new Promise((resolve) => {
242
386
  let settled = false
243
387
  let c: ReturnType<typeof createConnection> | undefined
@@ -249,11 +393,12 @@ export function rendezvousListening(id: string, timeoutMs = 800): Promise<Listen
249
393
  resolve(v)
250
394
  }
251
395
  const timer = setTimeout(() => done('unproven'), timeoutMs)
252
- try { c = createConnection({ path: rvSock(id) }) } catch { return done('unproven') }
396
+ try { c = createConnection({ path }) } catch { return done('unproven') }
253
397
  c.on('connect', () => done('live'))
254
398
  c.on('error', (e) => done(PROVEN_DEAD.has((e as NodeJS.ErrnoException).code ?? '') ? 'dead' : 'unproven'))
255
399
  })
256
400
  }
401
+ export const rendezvousListening = (id: string, timeoutMs = 800): Promise<ListenerProbe> => listenerAt(rvSock(id), timeoutMs)
257
402
  // The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
258
403
  // dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
259
404
  // dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
@@ -277,9 +422,40 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
277
422
  return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
278
423
  }
279
424
  export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
280
-
281
- function shQuote(s: string): string {
282
- return `'${s.replace(/'/g, `'\\''`)}'`
425
+ export const codexAppServerIsolation = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope')
426
+ type CodexRuntimeGenerationProof = Readonly<{
427
+ pid: number
428
+ startToken: string
429
+ processGroupId: number
430
+ sessionId: number
431
+ isolation: string
432
+ socket: Readonly<{ path: string; dev: number; ino: number }>
433
+ }>
434
+ function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
435
+ try {
436
+ const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
437
+ const start = processStartToken(pid)
438
+ const scope = readFileSync(codexAppServerIsolation(dir), 'utf8').trim()
439
+ const topology = processTopology(pid)
440
+ const socketPath = codexAppServerSock(dir)
441
+ const socket = statSync(socketPath)
442
+ if (!(pid > 0) || !start || !topology || topology.startToken !== start || topology.processGroupId !== pid || topology.sessionId !== pid ||
443
+ scope !== `detached-v3 ${pid} ${start} ${pid} ${pid}` || !socket.isSocket()) return null
444
+ return Object.freeze({
445
+ pid,
446
+ startToken: start,
447
+ processGroupId: topology.processGroupId,
448
+ sessionId: topology.sessionId,
449
+ isolation: scope,
450
+ socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
451
+ })
452
+ } catch { return null }
453
+ }
454
+ const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
455
+ `${proof.pid}|${proof.startToken}|${proof.processGroupId}|${proof.sessionId}|${proof.isolation}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
456
+ function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
457
+ const proof = codexRuntimeGenerationProof(dir)
458
+ return proof ? codexRuntimeGenerationToken(proof) : null
283
459
  }
284
460
 
285
461
  // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
@@ -369,6 +545,18 @@ export async function deliverViaRendezvous(id: string, text: string, wallMs?: nu
369
545
  return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
370
546
  }
371
547
 
548
+ export async function deliverViaSocketOrWake(
549
+ id: string,
550
+ text: string,
551
+ coldWake: () => Promise<DispatchResult>,
552
+ unprovenError: string,
553
+ ): Promise<DispatchResult> {
554
+ const probe = await rendezvousListening(id)
555
+ if (probe === 'live') return deliverViaRendezvous(id, text)
556
+ if (probe === 'unproven') return { ok: false, error: unprovenError }
557
+ return coldWake()
558
+ }
559
+
372
560
  type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
373
561
 
374
562
  // The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
@@ -463,10 +651,17 @@ export async function reportHeadlessTurnExit(id: string, harness: string, code:
463
651
  }
464
652
  }
465
653
 
466
- export function headlessTurnFailureShell(harness: string): string {
467
- return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc" || true`
654
+ export function headlessTurnFailureShell(harness: string, swallow = true): string {
655
+ return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc"${swallow ? ' || true' : ''}`
656
+ }
657
+ // @@@ sessionIdentityEnvVars - every environment variable that names ONE session: the launch-injected record
658
+ // id plus each adapter's own `sessionEnvVar`. Adapter-derived, so a new harness needs no edit here. A
659
+ // per-session process is entitled to carry them; a SHARED, project-scoped daemon must not — see the app-server
660
+ // spawn below.
661
+ export function sessionIdentityEnvVars(): string[] {
662
+ return [...new Set(['SPEXCODE_SESSION_ID', ...HARNESSES.map((h) => h.sessionEnvVar)])].filter(Boolean)
468
663
  }
469
- export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
664
+ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
470
665
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
471
666
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
472
667
  // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
@@ -478,12 +673,14 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
478
673
  const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
479
674
  const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
480
675
  const pid = codexAppServerPid(dir)
676
+ const isolation = codexAppServerIsolation(dir)
481
677
  const log = join(dir, 'codex-app-server.log')
482
678
  const lock = join(dir, 'codex-app-server.lock')
483
679
  const script = [
484
680
  `dir=${shQuote(dir)}`,
485
681
  `sock=${shQuote(sock)}`,
486
682
  `pid=${shQuote(pid)}`,
683
+ `isolation=${shQuote(isolation)}`,
487
684
  `log=${shQuote(log)}`,
488
685
  `lock=${shQuote(lock)}`,
489
686
  // codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
@@ -519,12 +716,25 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
519
716
  // removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
520
717
  // `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
521
718
  // until the daemon is killed. Running it from "$dir" (which never gets deleted) makes it deletion-proof.
522
- // exec so $! is the daemon itself; </dev/null detaches its stdin from the pane so it can't fight the TUI.
523
- ` ( cd "$dir" && exec ${server} app-server --listen unix://"$sock" >"$log" 2>&1 </dev/null ) &`,
524
- ' echo $! > "$pid"',
719
+ // For the SAME reason it must carry no session IDENTITY: it is started by whichever session happened to
720
+ // launch first and then serves every later thread, whose tool shells inherit its env — so a baked
721
+ // SPEXCODE_SESSION_ID (or any adapter's `sessionEnvVar`) is a stale lie for every session but one, and
722
+ // still a lie after that session closes and its record is swept (measured: daemons here running for days
723
+ // under a long-gone session's id). Everything downstream that resolves identity from the env then
724
+ // mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
725
+ // inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
726
+ // The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
727
+ // a boundary: the Codex Node launcher reset signal handling and died with the tmux pane despite a matching
728
+ // stamp. The internal helper uses child_process detached=true, records PID/start plus the observed pgrp/sid,
729
+ // and refuses unless the live process is its own session leader. The stop guard re-reads that topology.
730
+ ` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$isolation" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
525
731
  ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
526
732
  'fi',
527
733
  'rmdir "$lockd" 2>/dev/null',
734
+ // The delegated bearer arrived only through fd 9. The shared-spawn helper consumed it (or the already-live
735
+ // socket made it unnecessary); close and scrub the channel before the per-session Codex client starts.
736
+ '[ "${SPEXCODE_MAINTENANCE_DELEGATE_FD:-}" != "9" ] || exec 9<&-',
737
+ 'unset SPEXCODE_MAINTENANCE_DELEGATE_FD SPEXCODE_MAINTENANCE_SESSION_ID',
528
738
  // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
529
739
  // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
530
740
  // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
@@ -550,7 +760,11 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
550
760
  ]),
551
761
  `fi`,
552
762
  `[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
553
- ...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
763
+ // The visible TUI is the OTHER entry point that creates an execution context for this session (a fresh
764
+ // launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
765
+ // the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
766
+ // creates a context stamps that context's record id, and nothing downstream re-derives it.
767
+ ...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
554
768
  ].join('\n')
555
769
  return `bash -lc ${shQuote(script)} spexcode-codex`
556
770
  }
@@ -606,6 +820,239 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
606
820
  const WS_UPGRADE = (key: string) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`
607
821
  const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
608
822
 
823
+ // Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
824
+ // defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
825
+ type CodexGenerationFence = { dir: string; generation: string }
826
+ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive', threadId: string, fence?: CodexGenerationFence): Promise<{ ok: true } | { ok: false; error: string }> {
827
+ const generationError = () => fence && codexRuntimeGeneration(fence.dir) !== fence.generation
828
+ ? `Codex ${method} refused because the shared app-server generation changed`
829
+ : null
830
+ const before = generationError()
831
+ if (before) return Promise.resolve({ ok: false, error: before })
832
+ return new Promise((resolve) => {
833
+ const conn: Socket = createConnection(sock)
834
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
835
+ let upgraded = false, settled = false
836
+ const done = (r: { ok: true } | { ok: false; error: string }) => {
837
+ if (settled) return
838
+ settled = true
839
+ clearTimeout(timer)
840
+ try { conn.destroy() } catch {}
841
+ resolve(r)
842
+ }
843
+ // thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
844
+ // legitimate late response is not turned into an early commit-unknown race.
845
+ const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
846
+ conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
847
+ conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${method}` }) })
848
+ const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
849
+ conn.on('connect', () => {
850
+ const changed = generationError()
851
+ if (changed) return done({ ok: false, error: changed })
852
+ conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
853
+ })
854
+ const handle = (json: string) => {
855
+ let m: JsonRpc
856
+ try { m = JSON.parse(json) } catch { return }
857
+ if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}` })
858
+ if (m.id === 1 && m.result) {
859
+ const changed = generationError()
860
+ if (changed) return done({ ok: false, error: changed })
861
+ send({ method: 'initialized', params: {} })
862
+ return send({ id: 2, method, params: { threadId } })
863
+ }
864
+ if (m.id === 2 && m.result) {
865
+ const changed = generationError()
866
+ return changed ? done({ ok: false, error: changed }) : done({ ok: true })
867
+ }
868
+ }
869
+ conn.on('data', (chunk: Buffer) => {
870
+ fs.buf = Buffer.concat([fs.buf, chunk])
871
+ if (!upgraded) {
872
+ const i = fs.buf.indexOf('\r\n\r\n')
873
+ if (i < 0) return
874
+ const head = fs.buf.slice(0, i).toString('utf8')
875
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}` })
876
+ upgraded = true
877
+ fs.buf = fs.buf.slice(i + 4)
878
+ send(wsInitialize)
879
+ }
880
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${method}` })
881
+ })
882
+ })
883
+ }
884
+
885
+ type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
886
+ // Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
887
+ // entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
888
+ // supplies every protocol source kind explicitly for its thread/list calls.
889
+ export const CODEX_THREAD_SOURCE_KINDS = [
890
+ 'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
891
+ 'subAgentThreadSpawn', 'subAgentOther', 'unknown',
892
+ ] as const
893
+ function codexPagedIds(sock: string, method: 'thread/list' | 'thread/loaded/list', params: Record<string, unknown>, extractId: (item: unknown) => string | null, label: string): Promise<CodexPagedIdsResult> {
894
+ return new Promise((resolve) => {
895
+ const conn: Socket = createConnection(sock)
896
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
897
+ let upgraded = false, settled = false, requestId = 2, cursor: string | null = null
898
+ const ids = new Set<string>()
899
+ const done = (result: CodexPagedIdsResult) => {
900
+ if (settled) return
901
+ settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
902
+ }
903
+ const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out` }), 5000)
904
+ conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
905
+ conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
906
+ const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
907
+ const requestPage = () => send({ id: requestId, method, params: { ...params, ...(cursor ? { cursor } : {}), limit: 100 } })
908
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
909
+ const handle = (json: string) => {
910
+ let message: JsonRpc
911
+ try { message = JSON.parse(json) } catch { return }
912
+ if (message.error) return done({ ok: false, error: `Codex ${label} failed: ${message.error.message || JSON.stringify(message.error)}` })
913
+ if (message.id === 1 && message.result) { send({ method: 'initialized', params: {} }); return requestPage() }
914
+ if (message.id !== requestId || !message.result) return
915
+ const page = message.result as { data?: unknown; nextCursor?: unknown }
916
+ if (Array.isArray(page.data)) for (const item of page.data) {
917
+ const id = extractId(item)
918
+ if (typeof id === 'string') ids.add(id)
919
+ }
920
+ cursor = typeof page.nextCursor === 'string' && page.nextCursor ? page.nextCursor : null
921
+ if (!cursor) return done({ ok: true, ids: [...ids] })
922
+ requestId++
923
+ requestPage()
924
+ }
925
+ conn.on('data', (chunk: Buffer) => {
926
+ fs.buf = Buffer.concat([fs.buf, chunk])
927
+ if (!upgraded) {
928
+ const i = fs.buf.indexOf('\r\n\r\n')
929
+ if (i < 0) return
930
+ const head = fs.buf.slice(0, i).toString('utf8')
931
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused loaded-reference census: ${head.split('\r\n')[0]}` })
932
+ upgraded = true; fs.buf = fs.buf.slice(i + 4)
933
+ send(wsInitialize)
934
+ }
935
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${label}` })
936
+ })
937
+ })
938
+ }
939
+
940
+ // Lightweight resident census: unlike the full shared-runtime probe, this scans only paginated manager IDs
941
+ // and never issues thread/read includeTurns for each loaded reference.
942
+ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true; referenceIds: string[] } | { ok: false; error: string }> {
943
+ const result = await codexPagedIds(sock, 'thread/loaded/list', {}, (item) => {
944
+ if (typeof item === 'string') return item
945
+ const value = item as { id?: unknown; threadId?: unknown } | null
946
+ return typeof value?.id === 'string' ? value.id : typeof value?.threadId === 'string' ? value.threadId : null
947
+ }, 'loaded-reference census')
948
+ return result.ok ? { ok: true, referenceIds: result.ids } : result
949
+ }
950
+
951
+ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }> {
952
+ return new Promise((resolve) => {
953
+ const conn: Socket = createConnection(sock)
954
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
955
+ let upgraded = false, settled = false
956
+ const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }) => {
957
+ if (settled) return
958
+ settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
959
+ }
960
+ const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} read timed out after 5000ms` }), 5000)
961
+ conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} read failed: ${rpcError(error)}` }))
962
+ conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` }) })
963
+ const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
964
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
965
+ const handle = (json: string) => {
966
+ let message: JsonRpc
967
+ try { message = JSON.parse(json) } catch { return }
968
+ if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} read failed: ${message.error.message || JSON.stringify(message.error)}` })
969
+ if (message.id === 1 && message.result) {
970
+ send({ method: 'initialized', params: {} })
971
+ return send({ id: 2, method: 'thread/read', params: { threadId, includeTurns: true } })
972
+ }
973
+ if (message.id !== 2 || !message.result) return
974
+ const turns = (message.result as { thread?: { turns?: unknown } }).thread?.turns
975
+ if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
976
+ return done({ ok: true, turnPresence: activeTurnIdFromThread(message.result) ? 'active' : 'idle' })
977
+ }
978
+ conn.on('data', (chunk: Buffer) => {
979
+ fs.buf = Buffer.concat([fs.buf, chunk])
980
+ if (!upgraded) {
981
+ const i = fs.buf.indexOf('\r\n\r\n')
982
+ if (i < 0) return
983
+ const head = fs.buf.slice(0, i).toString('utf8')
984
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused target thread ${threadId} read: ${head.split('\r\n')[0]}` })
985
+ upgraded = true; fs.buf = fs.buf.slice(i + 4); send(wsInitialize)
986
+ }
987
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` })
988
+ })
989
+ })
990
+ }
991
+
992
+ // The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
993
+ // a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
994
+ export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
995
+ const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
996
+ ? params.sourceKinds
997
+ : [...CODEX_THREAD_SOURCE_KINDS]
998
+ return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
999
+ if (typeof item === 'string') return item
1000
+ const id = (item as { id?: unknown } | null)?.id
1001
+ return typeof id === 'string' ? id : null
1002
+ }, 'thread/list')
1003
+ }
1004
+
1005
+ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
1006
+ const generationBefore = codexRuntimeGeneration(dir)
1007
+ if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
1008
+ const sock = codexAppServerSock(dir)
1009
+ const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
1010
+ codexLoadedReferenceIds(sock),
1011
+ codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1012
+ codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1013
+ ])
1014
+ const referenceIds = loaded.ok ? loaded.referenceIds : []
1015
+ const descendantIds = activeDescendants.ok && archivedDescendants.ok
1016
+ ? [...new Set([...activeDescendants.ids, ...archivedDescendants.ids])]
1017
+ : []
1018
+ if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
1019
+ if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
1020
+ if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
1021
+ let targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = 'none'
1022
+ if (referenceIds.includes(threadId)) {
1023
+ const target = await codexTargetTurnPresence(sock, threadId)
1024
+ if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
1025
+ targetTurnPresence = target.turnPresence
1026
+ }
1027
+ if (codexRuntimeGeneration(dir) !== generationBefore)
1028
+ return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
1029
+ return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
1030
+ }
1031
+
1032
+ type CodexColdPreflight = { ok: true; alreadyCold?: boolean; guard: SharedRuntimeMutationGuard } | { ok: false; reason: string }
1033
+ async function codexColdPreflight(threadId: string, dir = runtimeRoot()): Promise<CodexColdPreflight> {
1034
+ const sock = codexAppServerSock(dir)
1035
+ const [guard, archivedList, activeList] = await Promise.all([
1036
+ codexTargetMutationGuard(threadId, dir),
1037
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1038
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1039
+ ])
1040
+ if (guard.descendantIds.length)
1041
+ return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); subtree archive is not proven safe` }
1042
+ if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
1043
+ if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1044
+ if (!activeList.ok) return { ok: false, reason: activeList.error }
1045
+ const inArchived = archivedList.ids.includes(threadId)
1046
+ const inActive = activeList.ids.includes(threadId)
1047
+ if (inArchived === inActive) return { ok: false, reason: `Codex thread ${threadId} has ambiguous disk collection state (archived=${inArchived}, active=${inActive})` }
1048
+ if (guard.targetTurnPresence === 'active') return { ok: false, reason: `Codex thread ${threadId} has an active turn; terminal-state confirmation is required before archive` }
1049
+ if (guard.targetTurnPresence === 'unknown') return { ok: false, reason: `Codex thread ${threadId} turn state is unknown; refusing to archive without terminal-state confirmation` }
1050
+ if (guard.targetTurnPresence === 'none')
1051
+ return inArchived ? { ok: true, alreadyCold: true, guard } : { ok: true, guard }
1052
+ if (inArchived) return { ok: false, reason: `Codex thread ${threadId} is archived but remains loaded` }
1053
+ return { ok: true, guard }
1054
+ }
1055
+
609
1056
  // Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
610
1057
  // id at launch (codexStartThread → stored on the record), this is only the DELIVERY FALLBACK for a pre-existing
611
1058
  // session whose id was never stored: it returns the first loaded thread. On a shared per-project server several
@@ -654,13 +1101,140 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
654
1101
  })
655
1102
  }
656
1103
 
1104
+ // Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
1105
+ // they are never treated as references by themselves. A loaded thread is a control-plane reference and its
1106
+ // fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
1107
+ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRuntimeProbe> {
1108
+ const sock = codexAppServerSock(dir)
1109
+ return (async () => {
1110
+ // File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
1111
+ // only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
1112
+ // root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
1113
+ let pid = 0
1114
+ try { pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim()) } catch { /* absent/stale */ }
1115
+ const pidLive = pid > 0 && !!processStartToken(pid)
1116
+ const listener = await listenerAt(sock, 800)
1117
+ if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
1118
+ if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
1119
+ return new Promise<SharedRuntimeProbe>((resolve) => {
1120
+ const conn: Socket = createConnection(sock)
1121
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1122
+ const references = new Map<string, SharedRuntimeProbe['references'][number]>()
1123
+ const requests = new Map<number, string>()
1124
+ const loadedRequests = new Set<number>()
1125
+ const loadedIds = new Set<string>()
1126
+ let loadedRequestId = 2
1127
+ let loadedCursor: string | null = null
1128
+ let upgraded = false
1129
+ let settled = false
1130
+ let timer: NodeJS.Timeout
1131
+ const done = (result: SharedRuntimeProbe) => {
1132
+ if (settled) return
1133
+ settled = true
1134
+ clearTimeout(timer)
1135
+ try { conn.destroy() } catch { /* */ }
1136
+ resolve(result)
1137
+ }
1138
+ const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
1139
+ timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
1140
+ conn.on('error', (e) => fail(`codex app-server ownership probe failed: ${rpcError(e)}`))
1141
+ conn.on('close', () => fail('codex app-server closed during ownership probe'))
1142
+ const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1143
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1144
+ const handle = (json: string) => {
1145
+ let m: JsonRpc
1146
+ try { m = JSON.parse(json) } catch { return }
1147
+ if (m.error) {
1148
+ if (typeof m.id === 'number' && loadedRequests.has(m.id)) return fail(`codex app-server loaded/list failed: ${m.error.message || JSON.stringify(m.error)}`)
1149
+ const request = typeof m.id === 'number' ? requests.get(m.id) : undefined
1150
+ if (request) {
1151
+ requests.delete(m.id!)
1152
+ if (!requests.size) done({ healthy: true, references: [...references.values()] })
1153
+ return
1154
+ }
1155
+ return fail(`codex app-server ownership request ${m.id ?? 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}`)
1156
+ }
1157
+ if (m.id === 1 && m.result) {
1158
+ send({ method: 'initialized', params: {} })
1159
+ loadedRequests.add(loadedRequestId)
1160
+ return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { limit: 100 } })
1161
+ }
1162
+ if (typeof m.id === 'number' && loadedRequests.has(m.id) && m.result) {
1163
+ loadedRequests.delete(m.id)
1164
+ const data = (m.result as { data?: unknown }).data
1165
+ const ids = [...new Set(Array.isArray(data) ? data.flatMap((item) => {
1166
+ if (typeof item === 'string') return [item]
1167
+ const id = (item as { id?: unknown; threadId?: unknown })?.id ?? (item as { threadId?: unknown })?.threadId
1168
+ return typeof id === 'string' ? [id] : []
1169
+ }) : [])]
1170
+ for (const threadId of ids) loadedIds.add(threadId)
1171
+ const next = (m.result as { nextCursor?: unknown }).nextCursor
1172
+ loadedCursor = typeof next === 'string' && next ? next : null
1173
+ if (loadedCursor) {
1174
+ loadedRequestId++
1175
+ loadedRequests.add(loadedRequestId)
1176
+ return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { cursor: loadedCursor, limit: 100 } })
1177
+ }
1178
+ // Continue with the complete paginated set, not just the first manager page.
1179
+ if (!loadedIds.size) return done({ healthy: true, references: [] })
1180
+ loadedIds.forEach((threadId) => {
1181
+ const id = 100 + requests.size
1182
+ references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' })
1183
+ requests.set(id, threadId)
1184
+ send({ id, method: 'thread/read', params: { threadId, includeTurns: true } })
1185
+ })
1186
+ return
1187
+ }
1188
+ if (typeof m.id === 'number' && requests.has(m.id) && m.result) {
1189
+ const threadId = requests.get(m.id)!
1190
+ requests.delete(m.id)
1191
+ const thread = (m.result as { thread?: { turns?: Array<{ id?: string; status?: string }> } }).thread
1192
+ const turnId = activeTurnIdFromThread(m.result)
1193
+ references.set(threadId, {
1194
+ referenceId: threadId,
1195
+ turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
1196
+ ...(turnId ? { turnId } : {}),
1197
+ })
1198
+ if (!requests.size) done({ healthy: true, references: [...references.values()] })
1199
+ }
1200
+ }
1201
+ conn.on('data', (chunk: Buffer) => {
1202
+ fs.buf = Buffer.concat([fs.buf, chunk])
1203
+ if (!upgraded) {
1204
+ const i = fs.buf.indexOf('\r\n\r\n')
1205
+ if (i < 0) return
1206
+ const head = fs.buf.slice(0, i).toString('utf8')
1207
+ if (!/^HTTP\/1\.1 101/.test(head)) return fail(`codex app-server refused ownership probe: ${head.split('\r\n')[0]}`)
1208
+ upgraded = true
1209
+ fs.buf = fs.buf.slice(i + 4)
1210
+ send(wsInitialize)
1211
+ }
1212
+ if (drainWsFrames(fs, conn, handle)) fail('codex app-server closed during ownership probe')
1213
+ })
1214
+ })
1215
+ })()
1216
+ }
1217
+
657
1218
  // @@@ codexStartThread - the BACKEND owns the thread. On the shared PER-PROJECT app-server we `thread/start
658
1219
  // { cwd }` (codex resolves config/hooks/AGENTS.md from that worktree cwd — exactly as claude loads CLAUDE.md
659
1220
  // per-worktree — so one project-scoped server behaves analogously to a per-worktree launch), and the result
660
1221
  // carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
661
1222
  // fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
662
1223
  // Never throws.
663
- export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
1224
+ // @@@ codexStartThreadParams - what a BACKEND-owned thread is created with. `config` is the per-request
1225
+ // override map (the only channel that reaches a thread): `bypass_hook_trust` so our hooks run, and
1226
+ // `shell_environment_policy.set` so every command this thread spawns carries the governed record id. The
1227
+ // latter is codex's answer to a structural fact — a codex tool shell descends from the SHARED app-server, so
1228
+ // it must inherit no identity and be given its own instead (verified live: the shell reports exactly the
1229
+ // injected id, and the launcher's env leaks nothing).
1230
+ export function codexStartThreadParams(cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Record<string, unknown> {
1231
+ const config = {
1232
+ ...(bypassHookTrust ? { bypass_hook_trust: true } : {}),
1233
+ ...(shellEnv && Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {}),
1234
+ }
1235
+ return { ...(cwd ? { cwd } : {}), ...(Object.keys(config).length ? { config } : {}) }
1236
+ }
1237
+ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
664
1238
  return new Promise((resolve) => {
665
1239
  const conn: Socket = createConnection(sock)
666
1240
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
@@ -690,8 +1264,13 @@ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = f
690
1264
  // own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
691
1265
  // stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
692
1266
  // the flag uses writeCodexTrust's hash and never sees this key).
693
- const params = { ...(cwd ? { cwd } : {}), ...(bypassHookTrust ? { config: { bypass_hook_trust: true } } : {}) }
694
- return send({ id: 2, method: 'thread/start', params })
1267
+ // The same override map carries the thread's IDENTITY. A codex tool shell is spawned by the SHARED
1268
+ // app-server, so it can inherit no session id — and must not, that leak was github#76. Codex's own
1269
+ // `shell_environment_policy.set` injects vars into every command THIS thread spawns, so the backend
1270
+ // stamps the governed record id there at thread creation, the same moment and the same knowledge with
1271
+ // which a claude launch bakes it into its agent's env. Identity then arrives per-thread, needing no
1272
+ // alias, no store lookup, and no cwd anywhere downstream.
1273
+ return send({ id: 2, method: 'thread/start', params: codexStartThreadParams(cwd, bypassHookTrust, shellEnv) })
695
1274
  }
696
1275
  if (m.id === 2 && m.result) {
697
1276
  const tid = (m.result as { thread?: { id?: string } })?.thread?.id
@@ -1129,10 +1708,57 @@ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
1129
1708
  return `bash -lc ${shQuote(script)} spexcode-opencode`
1130
1709
  }
1131
1710
 
1711
+ const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) =>
1712
+ (tmuxAlive && !!socketLive ? 'online' : 'offline')
1713
+
1714
+ const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
1715
+ (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
1716
+
1717
+ const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
1718
+
1719
+ // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
1720
+ // socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
1721
+ // faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
1722
+ // per-session resource NOT scoped by the store (`SPEXCODE_HOME`) or the tmux server (`SPEXCODE_TMUX`), so an
1723
+ // isolated instance closing an id that also names a LIVE session elsewhere had its `kill-session` miss (that
1724
+ // IS namespaced) while this unlink landed (it is not) — deleting a working agent's socket out from under it.
1725
+ // The damage is invisible and permanent: the listener stays bound to an unlinked path, so nothing can ever
1726
+ // connect again (delivery fails its existsSync gate) and every probe ENOENTs, which the liveness axis reads as
1727
+ // PROVEN death — a live worker reading `offline`, which in turn disarms the relaunch guard.
1728
+ // So: poll until death is proven, then unlink. A listener still answering past the wall is somebody's live
1729
+ // agent — mine that failed to die, or one that was never mine — and either way it is not ours to remove: leave
1730
+ // it and say so. `unproven` is not proof either, so it is left too. The asymmetry is deliberate: a dead-but-
1731
+ // unlinked file is harmless residue the next teardown reaps, while a wrong unlink strands a working agent.
1732
+ const SOCK_DEATH_WALL_MS = 2000 // a killed agent releases its listener in well under this; the wall only bounds the wrong case
1733
+ const SOCK_DEATH_POLL_MS = 100
1734
+ export const unlinkSocks = async (...paths: string[]): Promise<void> => {
1735
+ for (const path of paths) {
1736
+ if (!existsSync(path)) continue
1737
+ const deadline = Date.now() + SOCK_DEATH_WALL_MS
1738
+ let probe = await listenerAt(path)
1739
+ while (probe !== 'dead' && Date.now() < deadline) {
1740
+ await new Promise((r) => setTimeout(r, SOCK_DEATH_POLL_MS))
1741
+ probe = await listenerAt(path)
1742
+ }
1743
+ if (probe !== 'dead') {
1744
+ console.warn(`spex: left ${path} in place — ${probe === 'live'
1745
+ ? 'a listener is still answering it, so it belongs to a running agent (this teardown did not kill it, or it was never ours)'
1746
+ : 'the listener probe could not conclude, and death was never proven'}`)
1747
+ continue
1748
+ }
1749
+ try { rmSync(path, { force: true }) } catch { /* already gone */ }
1750
+ }
1751
+ }
1752
+
1753
+ const rendezvousLaunchEnv = (id: string): string[] => [
1754
+ 'CLAUDE_BG_BACKEND=daemon',
1755
+ `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`,
1756
+ ]
1757
+ const noLaunchEnv = (): string[] => []
1758
+
1132
1759
  export const claudeHarness: Harness = {
1133
1760
  id: 'claude',
1134
1761
  headless: false,
1135
- messageStream: false,
1136
1762
  events: CLAUDE_EVENTS,
1137
1763
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
1138
1764
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
@@ -1140,6 +1766,7 @@ export const claudeHarness: Harness = {
1140
1766
  baseCmd: claudeBaseCmd,
1141
1767
  sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
1142
1768
  sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
1769
+ launchEnv: rendezvousLaunchEnv,
1143
1770
  shimFile: (proj) => join(proj, '.claude', 'settings.json'),
1144
1771
  worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
1145
1772
  contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
@@ -1153,9 +1780,11 @@ export const claudeHarness: Harness = {
1153
1780
  // online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
1154
1781
  // the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
1155
1782
  // dead-pane-reads-working bug). See rendezvousListening.
1156
- liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
1783
+ liveness: socketListenerLiveness,
1784
+ leafOwnerNeedle: (rec) => rec.session,
1157
1785
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1158
- cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1786
+ cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
1787
+ coldRuntime: async () => ({ ok: true }),
1159
1788
  // the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
1160
1789
  // and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
1161
1790
  // the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
@@ -1166,6 +1795,11 @@ export const claudeHarness: Harness = {
1166
1795
  ? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
1167
1796
  : null,
1168
1797
  resumeArg: (rec) => `--resume ${rec.session}`,
1798
+ // claude's settled launch failures, in its own words: a `--resume` id it has no conversation for (the id was
1799
+ // never claude's, or its transcript is gone), and a rejected credential. Both are the same command failing
1800
+ // the same way every time — the human must repair the conversation or the login, so the transport stops at
1801
+ // one attempt and shows this line instead of burying it under two more identical failures.
1802
+ fatalLaunchOutput: ['No conversation found with session ID', 'Invalid API key', 'Please run /login'],
1169
1803
  }
1170
1804
 
1171
1805
  // Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
@@ -1174,23 +1808,25 @@ export const claudeHeadlessHarness: Harness = {
1174
1808
  ...claudeHarness,
1175
1809
  id: 'claude-headless',
1176
1810
  headless: true,
1177
- messageStream: true,
1811
+ runtimeOwnership: 'adapter',
1178
1812
  ownsRendezvous: false,
1179
1813
  paneTitleIsSelfSummary: false,
1180
1814
  launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
1181
- // Liveness is the intact record's property. A missing controller/child fails loudly at control time rather
1182
- // than turning an idle (no child) session into a speculative offline row.
1183
- liveness: () => 'online',
1815
+ launchEnv: noLaunchEnv,
1816
+ // Liveness is the intact, non-stopped record's property. A missing controller/child fails loudly at control
1817
+ // time rather than turning an idle (no child) session into a speculative offline row.
1818
+ liveness: recordOnline,
1184
1819
  deliver: deliverViaClaudeHeadless,
1185
1820
  interrupt: interruptClaudeHeadless,
1186
- cleanupRuntime: (rec) => { try { rmSync(claudeHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ } },
1821
+ cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
1822
+ coldRuntime: async () => ({ ok: false, reason: 'claude-headless has no exact resident unload verification' }),
1187
1823
  deliveryBlockedBy: undefined,
1188
1824
  }
1189
1825
 
1190
1826
  export const codexHarness: Harness = {
1191
1827
  id: 'codex',
1192
1828
  headless: false,
1193
- messageStream: false,
1829
+ sharedRuntimeSpawn: true,
1194
1830
  events: CODEX_EVENTS,
1195
1831
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
1196
1832
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
@@ -1198,6 +1834,7 @@ export const codexHarness: Harness = {
1198
1834
  baseCmd: codexBaseCmd,
1199
1835
  sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
1200
1836
  sessionEnvVar: 'CODEX_THREAD_ID',
1837
+ launchEnv: noLaunchEnv,
1201
1838
  // Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
1202
1839
  // (codex-rs `root_checkout_hooks_folder_for_dir` rewrites the hooks-config folder to <repo_root>/<rel>/.codex
1203
1840
  // for any linked worktree). Every worktree's thread (cwd = worktree root) therefore reads the SAME
@@ -1252,12 +1889,236 @@ export const codexHarness: Harness = {
1252
1889
  if (pane?.pidAlive !== undefined) return pane.pidAlive ? 'online' : 'offline'
1253
1890
  return paneTreeRunsCodex(pane) ? 'online' : 'offline'
1254
1891
  },
1892
+ leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
1255
1893
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
1256
- cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
1894
+ cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
1895
+ coldRetirementPreflight: async (rec) => {
1896
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1897
+ const threadId = rec.harnessSessionId
1898
+ const dir = runtimeRoot()
1899
+ const generationBefore = codexRuntimeGeneration(dir)
1900
+ if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1901
+ const sock = codexAppServerSock(dir)
1902
+ const [guard, archivedList, activeList] = await Promise.all([
1903
+ codexTargetMutationGuard(threadId, dir),
1904
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1905
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1906
+ ])
1907
+ if (codexRuntimeGeneration(dir) !== generationBefore)
1908
+ return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
1909
+ if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); cold retirement is not proven safe` }
1910
+ if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
1911
+ if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1912
+ if (!activeList.ok) return { ok: false, reason: activeList.error }
1913
+ if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} is still loaded` }
1914
+ const inArchived = archivedList.ids.includes(threadId)
1915
+ const inActive = activeList.ids.includes(threadId)
1916
+ if (!inArchived || inActive) return { ok: false, reason: `Codex thread ${threadId} is not uniquely in the archived collection (archived=${inArchived}, active=${inActive})` }
1917
+ return { ok: true, alreadyCold: true }
1918
+ },
1919
+ coldPreflight: async (rec) => {
1920
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1921
+ const result = await codexColdPreflight(rec.harnessSessionId)
1922
+ return result.ok ? { ok: true, ...(result.alreadyCold ? { alreadyCold: true } : {}) } : result
1923
+ },
1924
+ coldRuntime: async (rec) => {
1925
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1926
+ const threadId = rec.harnessSessionId
1927
+ const sock = codexAppServerSock(runtimeRoot())
1928
+ const generationBefore = codexRuntimeGeneration(runtimeRoot())
1929
+ if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1930
+ const preflight = await codexColdPreflight(threadId)
1931
+ if (preflight && !preflight.ok) return preflight
1932
+ if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during target guard' }
1933
+ if (preflight?.ok && preflight.alreadyCold) return { ok: true }
1934
+ const siblingBefore = preflight.guard.referenceIds.filter((referenceId) => referenceId !== threadId)
1935
+ const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
1936
+ const [guard, archivedList, activeList] = await Promise.all([
1937
+ codexTargetMutationGuard(threadId),
1938
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1939
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1940
+ ])
1941
+ if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
1942
+ if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} acquired owned descendants during archive (${guard.descendantIds.join(', ')})` }
1943
+ if (!guard.healthy) return { ok: false, reason: guard.error || 'Codex post-archive target guard is unhealthy' }
1944
+ if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1945
+ if (!activeList.ok) return { ok: false, reason: activeList.error }
1946
+ if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} remains loaded after thread/archive` }
1947
+ if (!archivedList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} is absent from both loaded and archived collections` }
1948
+ if (activeList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} remains in the non-archived collection` }
1949
+ const afterIds = new Set(guard.referenceIds.filter((referenceId) => referenceId !== threadId))
1950
+ if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
1951
+ return { ok: true }
1952
+ }
1953
+ const fence = { dir: runtimeRoot(), generation: generationBefore }
1954
+ const archived = await codexThreadMutation(sock, 'thread/archive', rec.harnessSessionId, fence)
1955
+ if (!archived.ok) {
1956
+ if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
1957
+ return { ok: false, reason: `${archived.error}; shared Codex app-server generation changed, so archive state is unknown and no compensation was attempted` }
1958
+ // RPC transport failure is commit-unknown. Reconcile collections before deciding whether compensation is needed.
1959
+ const [archivedList, activeList] = await Promise.all([
1960
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1961
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1962
+ ])
1963
+ if (archivedList.ok && archivedList.ids.includes(rec.harnessSessionId)) {
1964
+ const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
1965
+ const stillActive = await codexThreadList(sock, { archived: false, sourceKinds: [] })
1966
+ const suffix = restored.ok && stillActive.ok && stillActive.ids.includes(rec.harnessSessionId) ? '' : '; compensation/reconciliation failed'
1967
+ return { ok: false, reason: `${archived.error}${suffix}` }
1968
+ }
1969
+ return { ok: false, reason: archivedList.ok && activeList.ok ? archived.error : `${archived.error}; archive state is unknown and could not be reconciled` }
1970
+ }
1971
+ let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
1972
+ const verifyDeadline = Date.now() + 30_000
1973
+ for (let attempt = 0; attempt < 6 && Date.now() < verifyDeadline; attempt++) {
1974
+ verified = await coldCheck()
1975
+ if (verified.ok) break
1976
+ if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
1977
+ }
1978
+ if (verified.ok) return verified
1979
+ if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
1980
+ return { ok: false, reason: `${verified.reason}; shared Codex app-server generation changed, so no compensation was attempted` }
1981
+ const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
1982
+ const active = await codexThreadList(sock, { archived: false, sourceKinds: [] })
1983
+ const suffix = restored.ok && active.ok && active.ids.includes(rec.harnessSessionId) ? '' : '; compensation failed or archive state is unknown'
1984
+ return { ok: false, reason: `${verified.reason}${suffix}` }
1985
+ },
1986
+ restoreRuntime: async (rec) => {
1987
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1988
+ const sock = codexAppServerSock(runtimeRoot())
1989
+ const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
1990
+ const [active, archived] = await Promise.all([
1991
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1992
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1993
+ ])
1994
+ if (!active.ok || !archived.ok) return { ok: false, reason: 'Codex restore state could not be reconciled' }
1995
+ const inActive = active.ids.includes(rec.harnessSessionId!)
1996
+ const inArchived = archived.ids.includes(rec.harnessSessionId!)
1997
+ if (inActive && !inArchived) return { ok: true }
1998
+ if (inArchived && !inActive) return { ok: false, reason: 'Codex thread remains archived; restore can be retried' }
1999
+ return { ok: false, reason: 'Codex restore state is ambiguous (thread in both or neither collection)' }
2000
+ }
2001
+ const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId)
2002
+ if (!restored.ok) return reconcile()
2003
+ return reconcile()
2004
+ },
2005
+ sharedRuntimes: (runtimeDir) => [{
2006
+ key: 'codex-app-server',
2007
+ label: 'Codex app-server',
2008
+ pidFile: codexAppServerPid(runtimeDir),
2009
+ isolationFile: codexAppServerIsolation(runtimeDir),
2010
+ residency: async () => {
2011
+ const sock = codexAppServerSock(runtimeDir)
2012
+ let pid = 0
2013
+ try { pid = Number(readFileSync(codexAppServerPid(runtimeDir), 'utf8').trim()) } catch { /* stale/missing pid */ }
2014
+ const pidLive = pid > 0 && !!processStartToken(pid)
2015
+ const listener = await listenerAt(sock, 800)
2016
+ if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
2017
+ if (pidLive && (!codexRuntimeGeneration(runtimeDir) || listener !== 'live'))
2018
+ return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
2019
+ if (!pidLive || listener !== 'live')
2020
+ return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
2021
+ const result = await codexLoadedReferenceIds(sock)
2022
+ return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
2023
+ },
2024
+ mutationGuard: (targetReferenceId) => codexTargetMutationGuard(targetReferenceId, runtimeDir),
2025
+ probe: () => codexSharedRuntimeProbe(runtimeDir),
2026
+ }],
1257
2027
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
1258
2028
  // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
1259
2029
  // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
1260
2030
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
2031
+ // codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
2032
+ // that says so has already decided. (Its transient sibling — the rollout still being written — is handled
2033
+ // BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
2034
+ fatalLaunchOutput: ['no rollout found for thread id'],
2035
+ }
2036
+
2037
+ type CodexHeadlessLaunchReadinessProof = Readonly<{
2038
+ kind: 'codex-headless-shared-runtime'
2039
+ descriptorKey: string
2040
+ generation: CodexRuntimeGenerationProof
2041
+ target: Readonly<{
2042
+ sessionId: string
2043
+ threadId: string
2044
+ ownerSessionId: string
2045
+ ownerCount: 1
2046
+ ownerState: 'governed'
2047
+ referenceState: 'loaded'
2048
+ protectsControlPlane: true
2049
+ }>
2050
+ }>
2051
+
2052
+ const sameCodexHeadlessReadinessProof = (left: CodexHeadlessLaunchReadinessProof, right: CodexHeadlessLaunchReadinessProof) =>
2053
+ left.kind === right.kind &&
2054
+ left.descriptorKey === right.descriptorKey &&
2055
+ codexRuntimeGenerationToken(left.generation) === codexRuntimeGenerationToken(right.generation) &&
2056
+ left.target.sessionId === right.target.sessionId &&
2057
+ left.target.threadId === right.target.threadId &&
2058
+ left.target.ownerSessionId === right.target.ownerSessionId &&
2059
+ left.target.ownerCount === right.target.ownerCount &&
2060
+ left.target.ownerState === right.target.ownerState &&
2061
+ left.target.referenceState === right.target.referenceState &&
2062
+ left.target.protectsControlPlane === right.target.protectsControlPlane
2063
+
2064
+ const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string): string[] | null => {
2065
+ const root = join(runtimeDir, 'sessions')
2066
+ let entries
2067
+ try { entries = readdirSync(root, { withFileTypes: true }) }
2068
+ catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null }
2069
+ const owners: string[] = []
2070
+ for (const entry of entries) {
2071
+ if (!entry.isDirectory()) continue
2072
+ let parsed: unknown
2073
+ try { parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8')) }
2074
+ catch (error) {
2075
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
2076
+ return null
2077
+ }
2078
+ if (!parsed || typeof parsed !== 'object') return null
2079
+ const record = parsed as { session_id?: unknown; governed?: unknown; harness?: unknown; harness_session_id?: unknown }
2080
+ if (typeof record.session_id !== 'string') return null
2081
+ if (record.governed !== true) continue
2082
+ const harnessId = typeof record.harness === 'string' && record.harness ? record.harness : defaultHarness.id
2083
+ let sharesDescriptor = false
2084
+ try { sharesDescriptor = (harnessById(harnessId).sharedRuntimes?.(runtimeDir) ?? []).some((descriptor) => descriptor.key === descriptorKey) }
2085
+ catch { return null }
2086
+ if (sharesDescriptor && record.harness_session_id === threadId) owners.push(record.session_id)
2087
+ }
2088
+ return owners
2089
+ }
2090
+
2091
+ async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyRecord | null): Promise<CodexHeadlessLaunchReadinessProof | null> {
2092
+ const record = current()
2093
+ if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId) return null
2094
+ const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
2095
+ .find((candidate) => candidate.key === 'codex-app-server')
2096
+ if (!descriptor?.residency) return null
2097
+ const generationBefore = codexRuntimeGenerationProof(record.runtimeDir)
2098
+ if (!generationBefore) return null
2099
+ let resident: Awaited<ReturnType<NonNullable<SharedRuntimeDescriptor['residency']>>>
2100
+ try { resident = await descriptor.residency() }
2101
+ catch { return null }
2102
+ if (!resident.healthy) return null
2103
+ if (!resident.referenceIds.includes(record.harnessSessionId)) return null
2104
+ const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId)
2105
+ if (!owners || owners.length !== 1 || owners[0] !== record.session) return null
2106
+ const generationAfter = codexRuntimeGenerationProof(record.runtimeDir)
2107
+ if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter)) return null
2108
+ return Object.freeze({
2109
+ kind: 'codex-headless-shared-runtime',
2110
+ descriptorKey: descriptor.key,
2111
+ generation: generationAfter,
2112
+ target: Object.freeze({
2113
+ sessionId: record.session,
2114
+ threadId: record.harnessSessionId,
2115
+ ownerSessionId: owners[0],
2116
+ ownerCount: 1,
2117
+ ownerState: 'governed',
2118
+ referenceState: 'loaded',
2119
+ protectsControlPlane: true,
2120
+ }),
2121
+ })
1261
2122
  }
1262
2123
 
1263
2124
  // Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
@@ -1267,12 +2128,27 @@ export const codexHeadlessHarness: Harness = {
1267
2128
  ...codexHarness,
1268
2129
  id: 'codex-headless',
1269
2130
  headless: true,
2131
+ runtimeOwnership: 'adapter',
1270
2132
  launchOneShot: true,
1271
- messageStream: false,
1272
2133
  launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
1273
- // Record-backed liveness is the family contract for sleeping headless threads. A broken app-server or missing
1274
- // thread is surfaced by the inherited delivery call rather than converted into a speculative offline state.
1275
- liveness: () => 'online',
2134
+ // Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
2135
+ // offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
2136
+ liveness: recordOnline,
2137
+ launchReady: async (current, deadline) => {
2138
+ for (;;) {
2139
+ const proof = await codexHeadlessReadinessProof(current)
2140
+ if (proof) return {
2141
+ proof,
2142
+ validate: async (latest) => {
2143
+ const currentProof = await codexHeadlessReadinessProof(latest)
2144
+ return !!currentProof && sameCodexHeadlessReadinessProof(proof, currentProof)
2145
+ },
2146
+ }
2147
+ const remaining = deadline - Date.now()
2148
+ if (remaining <= 0) return null
2149
+ await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)))
2150
+ }
2151
+ },
1276
2152
  // There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
1277
2153
  // runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
1278
2154
  resumeArg: () => '',
@@ -1283,8 +2159,8 @@ export const codexHeadlessHarness: Harness = {
1283
2159
  // lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
1284
2160
  // hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
1285
2161
  // extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
1286
- // dispatch.sh AND binds this session's rendezvous socket itself (sessions.ts already exports
1287
- // CLAUDE_BG_RENDEZVOUS_SOCK to every ownsRendezvous launch) speaking the reclaude line protocol — so
2162
+ // dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
2163
+ // CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
1288
2164
  // deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
1289
2165
  // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
1290
2166
  // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
@@ -1292,7 +2168,6 @@ export const codexHeadlessHarness: Harness = {
1292
2168
  export const piHarness: Harness = {
1293
2169
  id: 'pi',
1294
2170
  headless: false,
1295
- messageStream: false,
1296
2171
  events: PI_EVENTS,
1297
2172
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
1298
2173
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
@@ -1300,6 +2175,7 @@ export const piHarness: Harness = {
1300
2175
  baseCmd: piBaseCmd,
1301
2176
  sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
1302
2177
  sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
2178
+ launchEnv: rendezvousLaunchEnv,
1303
2179
  shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
1304
2180
  worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
1305
2181
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
@@ -1315,9 +2191,11 @@ export const piHarness: Harness = {
1315
2191
  slashCommands: piSlashCommands,
1316
2192
  // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
1317
2193
  // socket the generated extension binds. socketLive is already probed for every windowed session.
1318
- liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
2194
+ liveness: socketListenerLiveness,
2195
+ leafOwnerNeedle: (rec) => rec.session,
1319
2196
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1320
- cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
2197
+ cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2198
+ coldRuntime: async () => ({ ok: true }),
1321
2199
  // reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
1322
2200
  // LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
1323
2201
  resumeArg: (rec) => `--session ${rec.session}`,
@@ -1331,15 +2209,13 @@ export const piHeadlessHarness: Harness = {
1331
2209
  ...piHarness,
1332
2210
  id: 'pi-headless',
1333
2211
  headless: true,
1334
- messageStream: false,
2212
+ runtimeOwnership: 'adapter',
1335
2213
  paneTitleIsSelfSummary: false,
1336
2214
  launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
1337
- liveness: () => 'online',
2215
+ liveness: recordOnline,
1338
2216
  deliver: deliverViaPiHeadless,
1339
- cleanupRuntime: (rec) => {
1340
- try { rmSync(piHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ }
1341
- try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ }
1342
- },
2217
+ cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
2218
+ coldRuntime: async () => ({ ok: false, reason: 'pi-headless has no exact resident unload verification' }),
1343
2219
  deliveryBlockedBy: undefined,
1344
2220
  resumeArg: (rec) => `--session ${rec.session}`,
1345
2221
  }
@@ -1347,7 +2223,6 @@ export const piHeadlessHarness: Harness = {
1347
2223
  export const opencodeHarness: Harness = {
1348
2224
  id: 'opencode',
1349
2225
  headless: false,
1350
- messageStream: false,
1351
2226
  events: OPENCODE_EVENTS,
1352
2227
  // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
1353
2228
  // socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
@@ -1362,6 +2237,7 @@ export const opencodeHarness: Harness = {
1362
2237
  // (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
1363
2238
  // SPEXCODE_SESSION_ID tier resolves the record.
1364
2239
  sessionEnvVar: 'OPENCODE_SESSION_ID',
2240
+ launchEnv: rendezvousLaunchEnv,
1365
2241
  // the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
1366
2242
  // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
1367
2243
  shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
@@ -1379,10 +2255,11 @@ export const opencodeHarness: Harness = {
1379
2255
  // online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
1380
2256
  // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
1381
2257
  // load still reads honestly from the process signal instead of a false offline.
1382
- liveness: (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
1383
- (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline'),
2258
+ liveness: socketListenerOrPidAliveLiveness,
2259
+ leafOwnerNeedle: (rec) => rec.session,
1384
2260
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1385
- cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
2261
+ cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2262
+ coldRuntime: async () => ({ ok: true }),
1386
2263
  // owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
1387
2264
  // SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
1388
2265
  // which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
@@ -1396,21 +2273,19 @@ export const opencodeHeadlessHarness: Harness = {
1396
2273
  ...opencodeHarness,
1397
2274
  id: 'opencode-headless',
1398
2275
  headless: true,
1399
- messageStream: false,
2276
+ runtimeOwnership: 'adapter',
1400
2277
  launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
1401
- // A sleeping native conversation is still addressable by its record. Transport breakage belongs to the
1402
- // next delivery, where the live rendezvous or pane wake reports it loudly.
1403
- liveness: () => 'online',
2278
+ // A sleeping native conversation is still addressable by its non-stopped record. Transport breakage belongs
2279
+ // to the next delivery, where the live rendezvous or pane wake reports it loudly.
2280
+ liveness: recordOnline,
2281
+ coldRuntime: async () => ({ ok: false, reason: 'opencode-headless has no exact resident unload verification' }),
1404
2282
  deliver: async (rec, text) => {
1405
- const probe = await rendezvousListening(rec.session)
1406
- if (probe === 'live') return deliverViaRendezvous(rec.session, text)
1407
- if (probe === 'unproven') {
1408
- return {
1409
- ok: false,
1410
- error: `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
1411
- }
1412
- }
1413
- return spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session))
2283
+ return deliverViaSocketOrWake(
2284
+ rec.session,
2285
+ text,
2286
+ () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)),
2287
+ `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
2288
+ )
1414
2289
  },
1415
2290
  }
1416
2291