spexcode 0.5.2 → 0.5.4

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 +15 -4
  17. package/spec-cli/src/harness.ts +1133 -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 +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  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 +174 -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 +88 -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 +1251 -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 +29 -15
  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 { detachedRuntimeGenerationToken, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } 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,86 @@ 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
+ receiptFile: 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, opts?: { coldReceipt?: unknown }) => 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
+ coldTeardownAuthorized?: boolean
71
+ error?: string
72
+ }
73
+ export type SharedRuntimeProbe = {
74
+ healthy: boolean
75
+ references: Array<{
76
+ referenceId: string
77
+ turnPresence: 'idle' | 'active' | 'unknown'
78
+ turnId?: string
79
+ }>
80
+ error?: string
81
+ }
82
+
83
+ export type HarnessColdPreflight =
84
+ | { ok: true; alreadyCold?: boolean; receipt?: unknown }
85
+ | { ok: false; reason: string }
86
+
87
+ export type AdapterLoadedReferenceState = {
88
+ healthy: boolean
89
+ loaded: boolean
90
+ error?: string
91
+ }
92
+
93
+ // One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
94
+ // once per call, then its result is joined to every record that names that adapter/thread. Product readers must
95
+ // not turn this into one RPC per row: a loaded thread can be externally reloaded after its cold proof was filed.
96
+ export async function adapterLoadedReferenceState(
97
+ records: readonly (HarnessLivenessRecord & { harness?: string })[],
98
+ runtimeDir = runtimeRoot(),
99
+ ): Promise<Map<string, AdapterLoadedReferenceState>> {
100
+ const descriptors = new Map<string, SharedRuntimeDescriptor>()
101
+ const recordKeys = new Map<string, string[]>()
102
+ for (const rec of records) {
103
+ if (!rec.harnessSessionId) continue
104
+ const keys = (harnessById(rec.harness || defaultHarness.id).sharedRuntimes?.(runtimeDir) ?? []).map((descriptor) => {
105
+ descriptors.set(descriptor.key, descriptor)
106
+ return descriptor.key
107
+ })
108
+ recordKeys.set(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`, keys)
109
+ }
110
+ const probes = await Promise.all([...descriptors.entries()].map(async ([key, descriptor]) => {
111
+ try {
112
+ const result = descriptor.residency
113
+ ? await descriptor.residency()
114
+ : await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
115
+ return [key, result] as const
116
+ }
117
+ catch (error) { return [key, { healthy: false, referenceIds: [] as string[], error: (error as Error).message }] as const }
118
+ }))
119
+ const byKey = new Map(probes)
120
+ const result = new Map<string, AdapterLoadedReferenceState>()
121
+ for (const [recordKey, keys] of recordKeys) {
122
+ const refs = keys.map((key) => byKey.get(key)!).filter(Boolean)
123
+ if (!refs.length) continue
124
+ const unhealthy = refs.find((probe) => !probe.healthy)
125
+ const threadId = recordKey.slice(recordKey.indexOf(':') + 1)
126
+ result.set(recordKey, unhealthy
127
+ ? { healthy: false, loaded: false, error: unhealthy.error || 'adapter resident-reference census is unhealthy' }
128
+ : { healthy: true, loaded: refs.some((probe) => probe.referenceIds.includes(threadId)) })
129
+ }
130
+ return result
131
+ }
45
132
 
46
133
  export interface Harness {
47
134
  readonly id: HarnessId
@@ -51,9 +138,18 @@ export interface Harness {
51
138
  // whether the launch command intentionally exits after its first turn instead of owning a resident process.
52
139
  // One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
53
140
  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
141
+ // Adapter-owned runtime shape: headless controllers/shared threads have no interactive TUI leaf to signal.
142
+ readonly runtimeOwnership?: 'leaf' | 'adapter'
143
+ // This launch command may create its project-shared control plane through internal shared-runtime-spawn.
144
+ readonly sharedRuntimeSpawn?: boolean
145
+ // @@@ fatalLaunchOutput - extended regexes matching THIS harness's own report of a launch failure that
146
+ // RUNNING IT AGAIN CANNOT FIX: a conversation that does not exist, a rejected credential, a broken config.
147
+ // A launcher that exits within the boot window tells us only that it exited fast, which is why the transport
148
+ // retries — but when the harness itself named a settled cause, retrying spends a certain failure two more
149
+ // times and buries the one line that explains it. So the transport asks the ADAPTER, and the adapter is the
150
+ // only place a harness's wording is ever matched: product code consumes the verdict (retry / fatal), never
151
+ // the text. A harness that declares none keeps the plain bounded retry.
152
+ readonly fatalLaunchOutput?: readonly string[]
57
153
  // the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
58
154
  // Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
59
155
  // idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
@@ -89,6 +185,9 @@ export interface Harness {
89
185
  sessionIdArg(id: string): string
90
186
  // the env var the agent's OWN process carries so its `spex …` calls know their session id.
91
187
  readonly sessionEnvVar: string
188
+ // transport bootstrap variables scoped to this launch. Rendezvous adapters own their daemon mode + socket;
189
+ // product launch code only composes these with generic session/home env.
190
+ launchEnv(id: string): string[]
92
191
 
93
192
  // --- materialize: shim + contract + trust ([[harness-delivery]]) ---
94
193
  // the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
@@ -148,6 +247,17 @@ export interface Harness {
148
247
  // to the shell). A missing probe (tmux/ps couldn't report) is not-live. The 'starting' boot
149
248
  // grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
150
249
  liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
250
+ // A completed launch command is only transport acceptance. An adapter with stronger runtime ownership may
251
+ // keep the caller waiting until the launched conversation is genuinely addressable. The lazy record source
252
+ // lets a one-shot launch publish its native id while readiness is pending. The returned adapter-owned fence
253
+ // names the facts that established readiness and revalidates those SAME facts across the caller's record
254
+ // commit. Null at the deadline is a launch failure; adapters without this seam retain the generic bounded
255
+ // liveness fence.
256
+ launchReady?(current: () => HarnessLaunchReadyRecord | null, deadline: number): Promise<HarnessLaunchReadinessFence | null>
257
+ // Exact leaf ownership evidence consumed by lifecycle teardown. The adapter returns the one argv identity
258
+ // token it registered for this record (session id, harness thread/generation, or null when unprovable);
259
+ // product lifecycle code never branches on harness names to invent this identity.
260
+ leafOwnerNeedle?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
151
261
  // deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
152
262
  // control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
153
263
  // before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
@@ -163,7 +273,24 @@ export interface Harness {
163
273
  // Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
164
274
  // launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
165
275
  // per-session socket. Product teardown calls only this adapter method.
166
- cleanupRuntime(rec: HarnessLivenessRecord): void
276
+ // Async because removal is CONDITIONAL on proof: a transport is only ours to remove once its listener is
277
+ // proven dead (see unlinkSocks), and that proof is a connect probe.
278
+ cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
279
+ // Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
280
+ // unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
281
+ // Its optional receipt is opaque adapter authority: product code may only pass the same object back to the
282
+ // stop guard and coldRuntime, never inspect it or synthesize a recursive/archive mode.
283
+ coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<HarnessColdPreflight>
284
+ // A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
285
+ // this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
286
+ coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
287
+ // Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
288
+ // reference or return a loud reason; adapters without such a resident reference return {ok:true}.
289
+ coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
290
+ restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
291
+ // Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
292
+ // references and protect a sibling-owned control plane without learning harness command names.
293
+ sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
167
294
  // the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
168
295
  // socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
169
296
  // the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
@@ -214,12 +341,36 @@ export type HarnessArtifacts = { skills: readonly string[]; agents: readonly str
214
341
  // sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
215
342
  // that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
216
343
  // `{"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`)
344
+ // and Enters can't be corrupted the way `tmux send-keys` was. It lives in tmpdir tied to the claude process, so
345
+ // no extra lifecycle. liveness CONNECTS to it (a live LISTENER, not merely the file see rendezvousListening);
346
+ // deliver writes to it.
347
+ //
348
+ // The path is a LAUNCH-TIME FACT, recorded not a formula every consumer re-derives. The id alone was not
349
+ // enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
350
+ // worlds on one box (a fixture, a migration, a copied record) can hold the same session id — and while the
351
+ // path ignored that scoping, they SHARED this socket. That is how an isolated teardown reached out and
352
+ // stranded a live production agent, and delivery would have crossed the same way. So the path a launch hands
353
+ // its agent is derived from the runtime the session belongs to (`runtimeRoot()` — the same identity that
354
+ // scopes its store) and STAMPED beside the record, exactly like `agent.pid`: a launch-time fact, readable by
355
+ // everyone who needs to reach that agent afterwards. Recording it (rather than re-deriving) also means the
356
+ // derivation can change again without stranding anything already running.
357
+ // `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
358
+ // the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
359
+ export const legacyRvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
360
+ export const scopedRvSock = (id: string, dir = runtimeRoot()) =>
361
+ join(tmpdir(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
362
+ const rvStamp = (id: string) => sessionArtifactPath(id, 'rv.path')
363
+ export const rvSock = (id: string): string => {
364
+ try { return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id) } catch { return legacyRvSock(id) }
365
+ }
366
+ // launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
367
+ // liveness probe, delivery, teardown) reads the one path the agent actually bound.
368
+ export function stampRvSock(id: string, dir = runtimeRoot()): string {
369
+ const path = scopedRvSock(id, dir)
370
+ mkdirSync(dirname(rvStamp(id)), { recursive: true })
371
+ writeFileSync(rvStamp(id), path)
372
+ return path
373
+ }
223
374
 
224
375
  // @@@ rendezvousListening - the LISTENER check that IS claude's liveness truth ([[state]], [[harness-adapter]]).
225
376
  // A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
@@ -237,7 +388,7 @@ export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
237
388
  // wedged/thrashed path. Never throws.
238
389
  export type ListenerProbe = 'live' | 'dead' | 'unproven'
239
390
  const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT'])
240
- export function rendezvousListening(id: string, timeoutMs = 800): Promise<ListenerProbe> {
391
+ export function listenerAt(path: string, timeoutMs = 800): Promise<ListenerProbe> {
241
392
  return new Promise((resolve) => {
242
393
  let settled = false
243
394
  let c: ReturnType<typeof createConnection> | undefined
@@ -249,11 +400,12 @@ export function rendezvousListening(id: string, timeoutMs = 800): Promise<Listen
249
400
  resolve(v)
250
401
  }
251
402
  const timer = setTimeout(() => done('unproven'), timeoutMs)
252
- try { c = createConnection({ path: rvSock(id) }) } catch { return done('unproven') }
403
+ try { c = createConnection({ path }) } catch { return done('unproven') }
253
404
  c.on('connect', () => done('live'))
254
405
  c.on('error', (e) => done(PROVEN_DEAD.has((e as NodeJS.ErrnoException).code ?? '') ? 'dead' : 'unproven'))
255
406
  })
256
407
  }
408
+ export const rendezvousListening = (id: string, timeoutMs = 800): Promise<ListenerProbe> => listenerAt(rvSock(id), timeoutMs)
257
409
  // The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
258
410
  // dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
259
411
  // dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
@@ -277,9 +429,29 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
277
429
  return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
278
430
  }
279
431
  export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
280
-
281
- function shQuote(s: string): string {
282
- return `'${s.replace(/'/g, `'\\''`)}'`
432
+ export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
433
+ type CodexRuntimeGenerationProof = Readonly<{
434
+ identity: VerifiedDetachedRuntime
435
+ socket: Readonly<{ path: string; dev: number; ino: number }>
436
+ }>
437
+ function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
438
+ try {
439
+ const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
440
+ const detached = verifyDetachedRuntime(pid, codexAppServerReceipt(dir))
441
+ const socketPath = codexAppServerSock(dir)
442
+ const socket = statSync(socketPath)
443
+ if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
444
+ return Object.freeze({
445
+ identity: detached.identity,
446
+ socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
447
+ })
448
+ } catch { return null }
449
+ }
450
+ const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
451
+ `${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
452
+ function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
453
+ const proof = codexRuntimeGenerationProof(dir)
454
+ return proof ? codexRuntimeGenerationToken(proof) : null
283
455
  }
284
456
 
285
457
  // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
@@ -369,6 +541,18 @@ export async function deliverViaRendezvous(id: string, text: string, wallMs?: nu
369
541
  return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
370
542
  }
371
543
 
544
+ export async function deliverViaSocketOrWake(
545
+ id: string,
546
+ text: string,
547
+ coldWake: () => Promise<DispatchResult>,
548
+ unprovenError: string,
549
+ ): Promise<DispatchResult> {
550
+ const probe = await rendezvousListening(id)
551
+ if (probe === 'live') return deliverViaRendezvous(id, text)
552
+ if (probe === 'unproven') return { ok: false, error: unprovenError }
553
+ return coldWake()
554
+ }
555
+
372
556
  type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
373
557
 
374
558
  // The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
@@ -463,10 +647,17 @@ export async function reportHeadlessTurnExit(id: string, harness: string, code:
463
647
  }
464
648
  }
465
649
 
466
- export function headlessTurnFailureShell(harness: string): string {
467
- return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc" || true`
650
+ export function headlessTurnFailureShell(harness: string, swallow = true): string {
651
+ return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc"${swallow ? ' || true' : ''}`
468
652
  }
469
- export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
653
+ // @@@ sessionIdentityEnvVars - every environment variable that names ONE session: the launch-injected record
654
+ // id plus each adapter's own `sessionEnvVar`. Adapter-derived, so a new harness needs no edit here. A
655
+ // per-session process is entitled to carry them; a SHARED, project-scoped daemon must not — see the app-server
656
+ // spawn below.
657
+ export function sessionIdentityEnvVars(): string[] {
658
+ return [...new Set(['SPEXCODE_SESSION_ID', ...HARNESSES.map((h) => h.sessionEnvVar)])].filter(Boolean)
659
+ }
660
+ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
470
661
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
471
662
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
472
663
  // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
@@ -478,12 +669,14 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
478
669
  const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
479
670
  const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
480
671
  const pid = codexAppServerPid(dir)
672
+ const receipt = codexAppServerReceipt(dir)
481
673
  const log = join(dir, 'codex-app-server.log')
482
674
  const lock = join(dir, 'codex-app-server.lock')
483
675
  const script = [
484
676
  `dir=${shQuote(dir)}`,
485
677
  `sock=${shQuote(sock)}`,
486
678
  `pid=${shQuote(pid)}`,
679
+ `receipt=${shQuote(receipt)}`,
487
680
  `log=${shQuote(log)}`,
488
681
  `lock=${shQuote(lock)}`,
489
682
  // codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
@@ -519,12 +712,25 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
519
712
  // removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
520
713
  // `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
521
714
  // 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"',
715
+ // For the SAME reason it must carry no session IDENTITY: it is started by whichever session happened to
716
+ // launch first and then serves every later thread, whose tool shells inherit its env — so a baked
717
+ // SPEXCODE_SESSION_ID (or any adapter's `sessionEnvVar`) is a stale lie for every session but one, and
718
+ // still a lie after that session closes and its record is swept (measured: daemons here running for days
719
+ // under a long-gone session's id). Everything downstream that resolves identity from the env then
720
+ // mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
721
+ // inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
722
+ // The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
723
+ // a boundary: the Codex Node launcher reset signal handling and died with the tmux pane. The internal helper
724
+ // uses child_process detached=true, then the process adapter publishes a private receipt only after proving
725
+ // exact PID/start + PGID (and Linux SID). Every consumer re-verifies that same receipt.
726
+ ` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$receipt" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
525
727
  ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
526
728
  'fi',
527
729
  'rmdir "$lockd" 2>/dev/null',
730
+ // The delegated bearer arrived only through fd 9. The shared-spawn helper consumed it (or the already-live
731
+ // socket made it unnecessary); close and scrub the channel before the per-session Codex client starts.
732
+ '[ "${SPEXCODE_MAINTENANCE_DELEGATE_FD:-}" != "9" ] || exec 9<&-',
733
+ 'unset SPEXCODE_MAINTENANCE_DELEGATE_FD SPEXCODE_MAINTENANCE_SESSION_ID',
528
734
  // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
529
735
  // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
530
736
  // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
@@ -550,7 +756,11 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
550
756
  ]),
551
757
  `fi`,
552
758
  `[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
553
- ...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
759
+ // The visible TUI is the OTHER entry point that creates an execution context for this session (a fresh
760
+ // launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
761
+ // the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
762
+ // creates a context stamps that context's record id, and nothing downstream re-derives it.
763
+ ...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
554
764
  ].join('\n')
555
765
  return `bash -lc ${shQuote(script)} spexcode-codex`
556
766
  }
@@ -606,6 +816,434 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
606
816
  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
817
  const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
608
818
 
819
+ // Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
820
+ // defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
821
+ type CodexGenerationFence = { dir: string; generation: string }
822
+ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive', threadId: string, fence?: CodexGenerationFence): Promise<{ ok: true } | { ok: false; error: string }> {
823
+ const generationError = () => fence && codexRuntimeGeneration(fence.dir) !== fence.generation
824
+ ? `Codex ${method} refused because the shared app-server generation changed`
825
+ : null
826
+ const before = generationError()
827
+ if (before) return Promise.resolve({ ok: false, error: before })
828
+ return new Promise((resolve) => {
829
+ const conn: Socket = createConnection(sock)
830
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
831
+ let upgraded = false, settled = false
832
+ const done = (r: { ok: true } | { ok: false; error: string }) => {
833
+ if (settled) return
834
+ settled = true
835
+ clearTimeout(timer)
836
+ try { conn.destroy() } catch {}
837
+ resolve(r)
838
+ }
839
+ // thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
840
+ // legitimate late response is not turned into an early commit-unknown race.
841
+ const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
842
+ conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
843
+ conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${method}` }) })
844
+ const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
845
+ conn.on('connect', () => {
846
+ const changed = generationError()
847
+ if (changed) return done({ ok: false, error: changed })
848
+ conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
849
+ })
850
+ const handle = (json: string) => {
851
+ let m: JsonRpc
852
+ try { m = JSON.parse(json) } catch { return }
853
+ if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}` })
854
+ if (m.id === 1 && m.result) {
855
+ const changed = generationError()
856
+ if (changed) return done({ ok: false, error: changed })
857
+ send({ method: 'initialized', params: {} })
858
+ return send({ id: 2, method, params: { threadId } })
859
+ }
860
+ if (m.id === 2 && m.result) {
861
+ const changed = generationError()
862
+ return changed ? done({ ok: false, error: changed }) : done({ ok: true })
863
+ }
864
+ }
865
+ conn.on('data', (chunk: Buffer) => {
866
+ fs.buf = Buffer.concat([fs.buf, chunk])
867
+ if (!upgraded) {
868
+ const i = fs.buf.indexOf('\r\n\r\n')
869
+ if (i < 0) return
870
+ const head = fs.buf.slice(0, i).toString('utf8')
871
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}` })
872
+ upgraded = true
873
+ fs.buf = fs.buf.slice(i + 4)
874
+ send(wsInitialize)
875
+ }
876
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${method}` })
877
+ })
878
+ })
879
+ }
880
+
881
+ type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
882
+ // Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
883
+ // entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
884
+ // supplies every protocol source kind explicitly for its thread/list calls.
885
+ export const CODEX_THREAD_SOURCE_KINDS = [
886
+ 'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
887
+ 'subAgentThreadSpawn', 'subAgentOther', 'unknown',
888
+ ] as const
889
+ function codexPagedIds(
890
+ sock: string,
891
+ method: 'thread/list' | 'thread/loaded/list',
892
+ params: Record<string, unknown>,
893
+ extractId: (item: unknown) => string | null,
894
+ label: string,
895
+ onItem?: (item: unknown) => void,
896
+ ): Promise<CodexPagedIdsResult> {
897
+ return new Promise((resolve) => {
898
+ const conn: Socket = createConnection(sock)
899
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
900
+ let upgraded = false, settled = false, requestId = 2, cursor: string | null = null
901
+ const ids = new Set<string>()
902
+ const done = (result: CodexPagedIdsResult) => {
903
+ if (settled) return
904
+ settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
905
+ }
906
+ const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out` }), 5000)
907
+ conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
908
+ conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
909
+ const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
910
+ const requestPage = () => send({ id: requestId, method, params: { ...params, ...(cursor ? { cursor } : {}), limit: 100 } })
911
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
912
+ const handle = (json: string) => {
913
+ let message: JsonRpc
914
+ try { message = JSON.parse(json) } catch { return }
915
+ if (message.error) return done({ ok: false, error: `Codex ${label} failed: ${message.error.message || JSON.stringify(message.error)}` })
916
+ if (message.id === 1 && message.result) { send({ method: 'initialized', params: {} }); return requestPage() }
917
+ if (message.id !== requestId || !message.result) return
918
+ const page = message.result as { data?: unknown; nextCursor?: unknown }
919
+ if (Array.isArray(page.data)) for (const item of page.data) {
920
+ onItem?.(item)
921
+ const id = extractId(item)
922
+ if (typeof id === 'string') ids.add(id)
923
+ }
924
+ cursor = typeof page.nextCursor === 'string' && page.nextCursor ? page.nextCursor : null
925
+ if (!cursor) return done({ ok: true, ids: [...ids] })
926
+ requestId++
927
+ requestPage()
928
+ }
929
+ conn.on('data', (chunk: Buffer) => {
930
+ fs.buf = Buffer.concat([fs.buf, chunk])
931
+ if (!upgraded) {
932
+ const i = fs.buf.indexOf('\r\n\r\n')
933
+ if (i < 0) return
934
+ const head = fs.buf.slice(0, i).toString('utf8')
935
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused loaded-reference census: ${head.split('\r\n')[0]}` })
936
+ upgraded = true; fs.buf = fs.buf.slice(i + 4)
937
+ send(wsInitialize)
938
+ }
939
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${label}` })
940
+ })
941
+ })
942
+ }
943
+
944
+ // Lightweight resident census: unlike the full shared-runtime probe, this scans only paginated manager IDs
945
+ // and never issues thread/read includeTurns for each loaded reference.
946
+ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true; referenceIds: string[] } | { ok: false; error: string }> {
947
+ const result = await codexPagedIds(sock, 'thread/loaded/list', {}, (item) => {
948
+ if (typeof item === 'string') return item
949
+ const value = item as { id?: unknown; threadId?: unknown } | null
950
+ return typeof value?.id === 'string' ? value.id : typeof value?.threadId === 'string' ? value.threadId : null
951
+ }, 'loaded-reference census')
952
+ return result.ok ? { ok: true, referenceIds: result.ids } : result
953
+ }
954
+
955
+ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }> {
956
+ return new Promise((resolve) => {
957
+ const conn: Socket = createConnection(sock)
958
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
959
+ let upgraded = false, settled = false
960
+ const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }) => {
961
+ if (settled) return
962
+ settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
963
+ }
964
+ const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} read timed out after 5000ms` }), 5000)
965
+ conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} read failed: ${rpcError(error)}` }))
966
+ conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` }) })
967
+ const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
968
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
969
+ const handle = (json: string) => {
970
+ let message: JsonRpc
971
+ try { message = JSON.parse(json) } catch { return }
972
+ if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} read failed: ${message.error.message || JSON.stringify(message.error)}` })
973
+ if (message.id === 1 && message.result) {
974
+ send({ method: 'initialized', params: {} })
975
+ return send({ id: 2, method: 'thread/read', params: { threadId, includeTurns: true } })
976
+ }
977
+ if (message.id !== 2 || !message.result) return
978
+ const turns = (message.result as { thread?: { turns?: unknown } }).thread?.turns
979
+ if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
980
+ return done({ ok: true, turnPresence: activeTurnIdFromThread(message.result) ? 'active' : 'idle' })
981
+ }
982
+ conn.on('data', (chunk: Buffer) => {
983
+ fs.buf = Buffer.concat([fs.buf, chunk])
984
+ if (!upgraded) {
985
+ const i = fs.buf.indexOf('\r\n\r\n')
986
+ if (i < 0) return
987
+ const head = fs.buf.slice(0, i).toString('utf8')
988
+ 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]}` })
989
+ upgraded = true; fs.buf = fs.buf.slice(i + 4); send(wsInitialize)
990
+ }
991
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` })
992
+ })
993
+ })
994
+ }
995
+
996
+ // The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
997
+ // a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
998
+ export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
999
+ return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
1000
+ }
1001
+
1002
+ type CodexThreadCollectionResult =
1003
+ | { ok: true; ids: string[]; parentById: Map<string, string | null> }
1004
+ | { ok: false; error: string }
1005
+
1006
+ function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
1007
+ const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
1008
+ ? params.sourceKinds
1009
+ : [...CODEX_THREAD_SOURCE_KINDS]
1010
+ const parentById = new Map<string, string | null>()
1011
+ const conflictingParents = new Set<string>()
1012
+ return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
1013
+ if (typeof item === 'string') return item
1014
+ const id = (item as { id?: unknown } | null)?.id
1015
+ return typeof id === 'string' ? id : null
1016
+ }, 'thread/list', (item) => {
1017
+ if (!item || typeof item !== 'object') return
1018
+ const row = item as { id?: unknown; parentThreadId?: unknown }
1019
+ if (typeof row.id !== 'string') return
1020
+ const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
1021
+ if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
1022
+ parentById.set(row.id, parent)
1023
+ }).then((result) => {
1024
+ if (!result.ok) return result
1025
+ if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
1026
+ return { ...result, parentById }
1027
+ })
1028
+ }
1029
+
1030
+ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
1031
+ const generationBefore = codexRuntimeGeneration(dir)
1032
+ if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
1033
+ const sock = codexAppServerSock(dir)
1034
+ const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
1035
+ codexLoadedReferenceIds(sock),
1036
+ codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1037
+ codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1038
+ ])
1039
+ const referenceIds = loaded.ok ? loaded.referenceIds : []
1040
+ const descendantIds = activeDescendants.ok && archivedDescendants.ok
1041
+ ? [...new Set([...activeDescendants.ids, ...archivedDescendants.ids])]
1042
+ : []
1043
+ if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
1044
+ if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
1045
+ if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
1046
+ let targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = 'none'
1047
+ if (referenceIds.includes(threadId)) {
1048
+ const target = await codexTargetTurnPresence(sock, threadId)
1049
+ if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
1050
+ targetTurnPresence = target.turnPresence
1051
+ }
1052
+ if (codexRuntimeGeneration(dir) !== generationBefore)
1053
+ return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
1054
+ return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
1055
+ }
1056
+
1057
+ const CODEX_COLD_PLAN = Symbol('codex-cold-plan')
1058
+ type CodexColdPlan = Readonly<{
1059
+ [CODEX_COLD_PLAN]: true
1060
+ kind: 'codex-cold-subtree-v1'
1061
+ threadId: string
1062
+ generation: string
1063
+ guard: SharedRuntimeMutationGuard
1064
+ descendantIds: readonly string[]
1065
+ parentEdges: readonly (readonly [string, string])[]
1066
+ subtreeIds: readonly string[]
1067
+ activeIds: readonly string[]
1068
+ archivedIds: readonly string[]
1069
+ }>
1070
+ type CodexColdPreflight = { ok: true; alreadyCold?: boolean; receipt: CodexColdPlan } | { ok: false; reason: string }
1071
+
1072
+ const sameIdSet = (left: readonly string[], right: readonly string[]) =>
1073
+ left.length === right.length && left.every((id) => right.includes(id))
1074
+
1075
+ const sameParentEdges = (left: readonly (readonly [string, string])[], right: readonly (readonly [string, string])[]) =>
1076
+ left.length === right.length && left.every(([id, parent]) => right.some(([otherId, otherParent]) => id === otherId && parent === otherParent))
1077
+
1078
+ const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
1079
+ if (!value || typeof value !== 'object') return false
1080
+ const plan = value as Partial<CodexColdPlan>
1081
+ return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
1082
+ typeof plan.generation === 'string' && Array.isArray(plan.descendantIds) &&
1083
+ Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
1084
+ Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
1085
+ }
1086
+
1087
+ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
1088
+ const generation = expectedGeneration ?? codexRuntimeGeneration(dir)
1089
+ if (!generation || codexRuntimeGeneration(dir) !== generation)
1090
+ return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
1091
+ const sock = codexAppServerSock(dir)
1092
+ const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
1093
+ codexLoadedReferenceIds(sock),
1094
+ codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1095
+ codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1096
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1097
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1098
+ ])
1099
+ if (codexRuntimeGeneration(dir) !== generation)
1100
+ return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
1101
+ if (!loaded.ok) return { ok: false, reason: loaded.error }
1102
+ if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
1103
+ if (!archivedDescendants.ok) return { ok: false, reason: archivedDescendants.error }
1104
+ if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1105
+ if (!activeList.ok) return { ok: false, reason: activeList.error }
1106
+
1107
+ const activeDescendantSet = new Set(activeDescendants.ids)
1108
+ const archivedDescendantSet = new Set(archivedDescendants.ids)
1109
+ const duplicateDescendants = activeDescendants.ids.filter((id) => archivedDescendantSet.has(id))
1110
+ if (duplicateDescendants.length)
1111
+ return { ok: false, reason: `Codex subtree members occur in both active and archived descendant collections (${duplicateDescendants.join(', ')})` }
1112
+ const descendantIds = [...activeDescendants.ids, ...archivedDescendants.ids]
1113
+ if (descendantIds.includes(threadId)) return { ok: false, reason: `Codex target ${threadId} is duplicated in its own descendant closure` }
1114
+
1115
+ const parentById = new Map([...activeDescendants.parentById, ...archivedDescendants.parentById])
1116
+ const depthById = new Map<string, number>()
1117
+ for (const id of descendantIds) {
1118
+ const seen = new Set([id])
1119
+ let cursor = id
1120
+ let depth = 0
1121
+ while (cursor !== threadId) {
1122
+ const next = parentById.get(cursor)
1123
+ if (!next) return { ok: false, reason: `Codex descendant ${id} has no complete parent chain to target ${threadId} (unowned or reassigned)` }
1124
+ if (seen.has(next)) return { ok: false, reason: `Codex descendant ${id} has a cyclic parent chain` }
1125
+ seen.add(next)
1126
+ cursor = next
1127
+ depth++
1128
+ }
1129
+ depthById.set(id, depth)
1130
+ }
1131
+
1132
+ const activeSet = new Set(activeList.ids)
1133
+ const archivedSet = new Set(archivedList.ids)
1134
+ const subtreeIds = [...descendantIds, threadId]
1135
+ for (const id of subtreeIds) {
1136
+ const inActive = activeSet.has(id)
1137
+ const inArchived = archivedSet.has(id)
1138
+ if (!inActive && !inArchived)
1139
+ return { ok: false, reason: `Codex subtree member ${id} is absent from both native collections (unowned or reassigned)` }
1140
+ if (inActive && inArchived)
1141
+ return { ok: false, reason: `Codex subtree member ${id} occurs in both active and archived native collections` }
1142
+ if (id !== threadId) {
1143
+ const expectedActive = activeDescendantSet.has(id)
1144
+ if (inActive !== expectedActive)
1145
+ return { ok: false, reason: `Codex subtree member ${id} changed collection assignment during ownership census` }
1146
+ }
1147
+ }
1148
+
1149
+ const loadedSet = new Set(loaded.referenceIds)
1150
+ const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
1151
+ const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
1152
+ if (codexRuntimeGeneration(dir) !== generation)
1153
+ return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
1154
+ for (const { id, state } of turnStates) {
1155
+ if (!state.ok) return { ok: false, reason: state.error }
1156
+ if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
1157
+ if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
1158
+ if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
1159
+ }
1160
+
1161
+ const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
1162
+ const guard: SharedRuntimeMutationGuard = {
1163
+ healthy: true,
1164
+ referenceIds: [...loaded.referenceIds],
1165
+ targetTurnPresence,
1166
+ descendantIds: [...descendantIds],
1167
+ }
1168
+ const activeIds = [...activeDescendants.ids]
1169
+ .sort((left, right) => (depthById.get(right) ?? 0) - (depthById.get(left) ?? 0))
1170
+ .concat(activeSet.has(threadId) ? [threadId] : [])
1171
+ const archivedIds = [...archivedDescendants.ids, ...(archivedSet.has(threadId) ? [threadId] : [])]
1172
+ const parentEdges = descendantIds.map((id) => [id, parentById.get(id)!] as const)
1173
+ const receipt: CodexColdPlan = Object.freeze({
1174
+ [CODEX_COLD_PLAN]: true as const,
1175
+ kind: 'codex-cold-subtree-v1',
1176
+ threadId,
1177
+ generation,
1178
+ guard,
1179
+ descendantIds: Object.freeze([...descendantIds]),
1180
+ parentEdges: Object.freeze(parentEdges),
1181
+ subtreeIds: Object.freeze([...subtreeIds]),
1182
+ activeIds: Object.freeze(activeIds),
1183
+ archivedIds: Object.freeze(archivedIds),
1184
+ })
1185
+ return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
1186
+ }
1187
+
1188
+ async function codexMutationGuard(
1189
+ threadId: string,
1190
+ dir = runtimeRoot(),
1191
+ opts: { coldReceipt?: unknown } = {},
1192
+ ): Promise<SharedRuntimeMutationGuard> {
1193
+ if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir)
1194
+ if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
1195
+ return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
1196
+ const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation)
1197
+ if (!current.ok) {
1198
+ const guard = await codexTargetMutationGuard(threadId, dir)
1199
+ return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
1200
+ }
1201
+ const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
1202
+ sameParentEdges(opts.coldReceipt.parentEdges, current.receipt.parentEdges) &&
1203
+ sameIdSet(opts.coldReceipt.activeIds, current.receipt.activeIds) &&
1204
+ sameIdSet(opts.coldReceipt.archivedIds, current.receipt.archivedIds)
1205
+ return {
1206
+ ...current.receipt.guard,
1207
+ healthy: authorized,
1208
+ coldTeardownAuthorized: authorized,
1209
+ ...(authorized ? {} : { error: 'adapter cold teardown receipt no longer matches the target subtree' }),
1210
+ }
1211
+ }
1212
+
1213
+ async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
1214
+ if (codexRuntimeGeneration(dir) !== plan.generation)
1215
+ return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1216
+ const sock = codexAppServerSock(dir)
1217
+ const [activeBefore, archivedBefore] = await Promise.all([
1218
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1219
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1220
+ ])
1221
+ if (!activeBefore.ok || !archivedBefore.ok)
1222
+ return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
1223
+ if (codexRuntimeGeneration(dir) !== plan.generation)
1224
+ return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1225
+ const activeSet = new Set(activeBefore.ids)
1226
+ const archivedSet = new Set(archivedBefore.ids)
1227
+ if (plan.archivedIds.some((id) => !archivedSet.has(id) || activeSet.has(id)))
1228
+ return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
1229
+ if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
1230
+ return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
1231
+ const fence = { dir, generation: plan.generation }
1232
+ const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
1233
+ for (const id of restoreIds) {
1234
+ const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
1235
+ if (!restored.ok) return { ok: false, reason: `compensation failed for ${id}: ${restored.error}` }
1236
+ }
1237
+ const [activeAfter, archivedAfter] = await Promise.all([
1238
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1239
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1240
+ ])
1241
+ const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir) === plan.generation &&
1242
+ plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
1243
+ plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
1244
+ return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
1245
+ }
1246
+
609
1247
  // Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
610
1248
  // id at launch (codexStartThread → stored on the record), this is only the DELIVERY FALLBACK for a pre-existing
611
1249
  // session whose id was never stored: it returns the first loaded thread. On a shared per-project server several
@@ -654,13 +1292,144 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
654
1292
  })
655
1293
  }
656
1294
 
1295
+ // Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
1296
+ // they are never treated as references by themselves. A loaded thread is a control-plane reference and its
1297
+ // fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
1298
+ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRuntimeProbe> {
1299
+ const sock = codexAppServerSock(dir)
1300
+ return (async () => {
1301
+ // File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
1302
+ // only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
1303
+ // root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
1304
+ let pid = 0
1305
+ try { pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim()) } catch { /* absent/stale */ }
1306
+ const pidLive = pid > 0 && !!processStartToken(pid)
1307
+ const listener = await listenerAt(sock, 800)
1308
+ if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
1309
+ if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
1310
+ const generation = codexRuntimeGeneration(dir)
1311
+ if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
1312
+ return new Promise<SharedRuntimeProbe>((resolve) => {
1313
+ const conn: Socket = createConnection(sock)
1314
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1315
+ const references = new Map<string, SharedRuntimeProbe['references'][number]>()
1316
+ const requests = new Map<number, string>()
1317
+ const loadedRequests = new Set<number>()
1318
+ const loadedIds = new Set<string>()
1319
+ let loadedRequestId = 2
1320
+ let loadedCursor: string | null = null
1321
+ let upgraded = false
1322
+ let settled = false
1323
+ let timer: NodeJS.Timeout
1324
+ const done = (result: SharedRuntimeProbe) => {
1325
+ if (settled) return
1326
+ settled = true
1327
+ clearTimeout(timer)
1328
+ try { conn.destroy() } catch { /* */ }
1329
+ resolve(result.healthy && codexRuntimeGeneration(dir) !== generation
1330
+ ? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
1331
+ : result)
1332
+ }
1333
+ const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
1334
+ timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
1335
+ conn.on('error', (e) => fail(`codex app-server ownership probe failed: ${rpcError(e)}`))
1336
+ conn.on('close', () => fail('codex app-server closed during ownership probe'))
1337
+ const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1338
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1339
+ const handle = (json: string) => {
1340
+ let m: JsonRpc
1341
+ try { m = JSON.parse(json) } catch { return }
1342
+ if (m.error) {
1343
+ if (typeof m.id === 'number' && loadedRequests.has(m.id)) return fail(`codex app-server loaded/list failed: ${m.error.message || JSON.stringify(m.error)}`)
1344
+ const request = typeof m.id === 'number' ? requests.get(m.id) : undefined
1345
+ if (request) {
1346
+ requests.delete(m.id!)
1347
+ if (!requests.size) done({ healthy: true, references: [...references.values()] })
1348
+ return
1349
+ }
1350
+ return fail(`codex app-server ownership request ${m.id ?? 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}`)
1351
+ }
1352
+ if (m.id === 1 && m.result) {
1353
+ send({ method: 'initialized', params: {} })
1354
+ loadedRequests.add(loadedRequestId)
1355
+ return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { limit: 100 } })
1356
+ }
1357
+ if (typeof m.id === 'number' && loadedRequests.has(m.id) && m.result) {
1358
+ loadedRequests.delete(m.id)
1359
+ const data = (m.result as { data?: unknown }).data
1360
+ const ids = [...new Set(Array.isArray(data) ? data.flatMap((item) => {
1361
+ if (typeof item === 'string') return [item]
1362
+ const id = (item as { id?: unknown; threadId?: unknown })?.id ?? (item as { threadId?: unknown })?.threadId
1363
+ return typeof id === 'string' ? [id] : []
1364
+ }) : [])]
1365
+ for (const threadId of ids) loadedIds.add(threadId)
1366
+ const next = (m.result as { nextCursor?: unknown }).nextCursor
1367
+ loadedCursor = typeof next === 'string' && next ? next : null
1368
+ if (loadedCursor) {
1369
+ loadedRequestId++
1370
+ loadedRequests.add(loadedRequestId)
1371
+ return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { cursor: loadedCursor, limit: 100 } })
1372
+ }
1373
+ // Continue with the complete paginated set, not just the first manager page.
1374
+ if (!loadedIds.size) return done({ healthy: true, references: [] })
1375
+ loadedIds.forEach((threadId) => {
1376
+ const id = 100 + requests.size
1377
+ references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' })
1378
+ requests.set(id, threadId)
1379
+ send({ id, method: 'thread/read', params: { threadId, includeTurns: true } })
1380
+ })
1381
+ return
1382
+ }
1383
+ if (typeof m.id === 'number' && requests.has(m.id) && m.result) {
1384
+ const threadId = requests.get(m.id)!
1385
+ requests.delete(m.id)
1386
+ const thread = (m.result as { thread?: { turns?: Array<{ id?: string; status?: string }> } }).thread
1387
+ const turnId = activeTurnIdFromThread(m.result)
1388
+ references.set(threadId, {
1389
+ referenceId: threadId,
1390
+ turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
1391
+ ...(turnId ? { turnId } : {}),
1392
+ })
1393
+ if (!requests.size) done({ healthy: true, references: [...references.values()] })
1394
+ }
1395
+ }
1396
+ conn.on('data', (chunk: Buffer) => {
1397
+ fs.buf = Buffer.concat([fs.buf, chunk])
1398
+ if (!upgraded) {
1399
+ const i = fs.buf.indexOf('\r\n\r\n')
1400
+ if (i < 0) return
1401
+ const head = fs.buf.slice(0, i).toString('utf8')
1402
+ if (!/^HTTP\/1\.1 101/.test(head)) return fail(`codex app-server refused ownership probe: ${head.split('\r\n')[0]}`)
1403
+ upgraded = true
1404
+ fs.buf = fs.buf.slice(i + 4)
1405
+ send(wsInitialize)
1406
+ }
1407
+ if (drainWsFrames(fs, conn, handle)) fail('codex app-server closed during ownership probe')
1408
+ })
1409
+ })
1410
+ })()
1411
+ }
1412
+
657
1413
  // @@@ codexStartThread - the BACKEND owns the thread. On the shared PER-PROJECT app-server we `thread/start
658
1414
  // { cwd }` (codex resolves config/hooks/AGENTS.md from that worktree cwd — exactly as claude loads CLAUDE.md
659
1415
  // per-worktree — so one project-scoped server behaves analogously to a per-worktree launch), and the result
660
1416
  // carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
661
1417
  // fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
662
1418
  // Never throws.
663
- export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
1419
+ // @@@ codexStartThreadParams - what a BACKEND-owned thread is created with. `config` is the per-request
1420
+ // override map (the only channel that reaches a thread): `bypass_hook_trust` so our hooks run, and
1421
+ // `shell_environment_policy.set` so every command this thread spawns carries the governed record id. The
1422
+ // latter is codex's answer to a structural fact — a codex tool shell descends from the SHARED app-server, so
1423
+ // it must inherit no identity and be given its own instead (verified live: the shell reports exactly the
1424
+ // injected id, and the launcher's env leaks nothing).
1425
+ export function codexStartThreadParams(cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Record<string, unknown> {
1426
+ const config = {
1427
+ ...(bypassHookTrust ? { bypass_hook_trust: true } : {}),
1428
+ ...(shellEnv && Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {}),
1429
+ }
1430
+ return { ...(cwd ? { cwd } : {}), ...(Object.keys(config).length ? { config } : {}) }
1431
+ }
1432
+ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
664
1433
  return new Promise((resolve) => {
665
1434
  const conn: Socket = createConnection(sock)
666
1435
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
@@ -690,8 +1459,13 @@ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = f
690
1459
  // own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
691
1460
  // stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
692
1461
  // 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 })
1462
+ // The same override map carries the thread's IDENTITY. A codex tool shell is spawned by the SHARED
1463
+ // app-server, so it can inherit no session id — and must not, that leak was github#76. Codex's own
1464
+ // `shell_environment_policy.set` injects vars into every command THIS thread spawns, so the backend
1465
+ // stamps the governed record id there at thread creation, the same moment and the same knowledge with
1466
+ // which a claude launch bakes it into its agent's env. Identity then arrives per-thread, needing no
1467
+ // alias, no store lookup, and no cwd anywhere downstream.
1468
+ return send({ id: 2, method: 'thread/start', params: codexStartThreadParams(cwd, bypassHookTrust, shellEnv) })
695
1469
  }
696
1470
  if (m.id === 2 && m.result) {
697
1471
  const tid = (m.result as { thread?: { id?: string } })?.thread?.id
@@ -1129,10 +1903,57 @@ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
1129
1903
  return `bash -lc ${shQuote(script)} spexcode-opencode`
1130
1904
  }
1131
1905
 
1906
+ const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) =>
1907
+ (tmuxAlive && !!socketLive ? 'online' : 'offline')
1908
+
1909
+ const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
1910
+ (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
1911
+
1912
+ const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
1913
+
1914
+ // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
1915
+ // socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
1916
+ // faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
1917
+ // per-session resource NOT scoped by the store (`SPEXCODE_HOME`) or the tmux server (`SPEXCODE_TMUX`), so an
1918
+ // isolated instance closing an id that also names a LIVE session elsewhere had its `kill-session` miss (that
1919
+ // IS namespaced) while this unlink landed (it is not) — deleting a working agent's socket out from under it.
1920
+ // The damage is invisible and permanent: the listener stays bound to an unlinked path, so nothing can ever
1921
+ // connect again (delivery fails its existsSync gate) and every probe ENOENTs, which the liveness axis reads as
1922
+ // PROVEN death — a live worker reading `offline`, which in turn disarms the relaunch guard.
1923
+ // So: poll until death is proven, then unlink. A listener still answering past the wall is somebody's live
1924
+ // agent — mine that failed to die, or one that was never mine — and either way it is not ours to remove: leave
1925
+ // it and say so. `unproven` is not proof either, so it is left too. The asymmetry is deliberate: a dead-but-
1926
+ // unlinked file is harmless residue the next teardown reaps, while a wrong unlink strands a working agent.
1927
+ const SOCK_DEATH_WALL_MS = 2000 // a killed agent releases its listener in well under this; the wall only bounds the wrong case
1928
+ const SOCK_DEATH_POLL_MS = 100
1929
+ export const unlinkSocks = async (...paths: string[]): Promise<void> => {
1930
+ for (const path of paths) {
1931
+ if (!existsSync(path)) continue
1932
+ const deadline = Date.now() + SOCK_DEATH_WALL_MS
1933
+ let probe = await listenerAt(path)
1934
+ while (probe !== 'dead' && Date.now() < deadline) {
1935
+ await new Promise((r) => setTimeout(r, SOCK_DEATH_POLL_MS))
1936
+ probe = await listenerAt(path)
1937
+ }
1938
+ if (probe !== 'dead') {
1939
+ console.warn(`spex: left ${path} in place — ${probe === 'live'
1940
+ ? 'a listener is still answering it, so it belongs to a running agent (this teardown did not kill it, or it was never ours)'
1941
+ : 'the listener probe could not conclude, and death was never proven'}`)
1942
+ continue
1943
+ }
1944
+ try { rmSync(path, { force: true }) } catch { /* already gone */ }
1945
+ }
1946
+ }
1947
+
1948
+ const rendezvousLaunchEnv = (id: string): string[] => [
1949
+ 'CLAUDE_BG_BACKEND=daemon',
1950
+ `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`,
1951
+ ]
1952
+ const noLaunchEnv = (): string[] => []
1953
+
1132
1954
  export const claudeHarness: Harness = {
1133
1955
  id: 'claude',
1134
1956
  headless: false,
1135
- messageStream: false,
1136
1957
  events: CLAUDE_EVENTS,
1137
1958
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
1138
1959
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
@@ -1140,6 +1961,7 @@ export const claudeHarness: Harness = {
1140
1961
  baseCmd: claudeBaseCmd,
1141
1962
  sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
1142
1963
  sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
1964
+ launchEnv: rendezvousLaunchEnv,
1143
1965
  shimFile: (proj) => join(proj, '.claude', 'settings.json'),
1144
1966
  worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
1145
1967
  contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
@@ -1153,9 +1975,11 @@ export const claudeHarness: Harness = {
1153
1975
  // online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
1154
1976
  // the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
1155
1977
  // dead-pane-reads-working bug). See rendezvousListening.
1156
- liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
1978
+ liveness: socketListenerLiveness,
1979
+ leafOwnerNeedle: (rec) => rec.session,
1157
1980
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1158
- cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1981
+ cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
1982
+ coldRuntime: async () => ({ ok: true }),
1159
1983
  // the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
1160
1984
  // and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
1161
1985
  // the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
@@ -1166,6 +1990,11 @@ export const claudeHarness: Harness = {
1166
1990
  ? '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
1991
  : null,
1168
1992
  resumeArg: (rec) => `--resume ${rec.session}`,
1993
+ // claude's settled launch failures, in its own words: a `--resume` id it has no conversation for (the id was
1994
+ // never claude's, or its transcript is gone), and a rejected credential. Both are the same command failing
1995
+ // the same way every time — the human must repair the conversation or the login, so the transport stops at
1996
+ // one attempt and shows this line instead of burying it under two more identical failures.
1997
+ fatalLaunchOutput: ['No conversation found with session ID', 'Invalid API key', 'Please run /login'],
1169
1998
  }
1170
1999
 
1171
2000
  // Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
@@ -1174,23 +2003,25 @@ export const claudeHeadlessHarness: Harness = {
1174
2003
  ...claudeHarness,
1175
2004
  id: 'claude-headless',
1176
2005
  headless: true,
1177
- messageStream: true,
2006
+ runtimeOwnership: 'adapter',
1178
2007
  ownsRendezvous: false,
1179
2008
  paneTitleIsSelfSummary: false,
1180
2009
  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',
2010
+ launchEnv: noLaunchEnv,
2011
+ // Liveness is the intact, non-stopped record's property. A missing controller/child fails loudly at control
2012
+ // time rather than turning an idle (no child) session into a speculative offline row.
2013
+ liveness: recordOnline,
1184
2014
  deliver: deliverViaClaudeHeadless,
1185
2015
  interrupt: interruptClaudeHeadless,
1186
- cleanupRuntime: (rec) => { try { rmSync(claudeHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ } },
2016
+ cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
2017
+ coldRuntime: async () => ({ ok: false, reason: 'claude-headless has no exact resident unload verification' }),
1187
2018
  deliveryBlockedBy: undefined,
1188
2019
  }
1189
2020
 
1190
2021
  export const codexHarness: Harness = {
1191
2022
  id: 'codex',
1192
2023
  headless: false,
1193
- messageStream: false,
2024
+ sharedRuntimeSpawn: true,
1194
2025
  events: CODEX_EVENTS,
1195
2026
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
1196
2027
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
@@ -1198,6 +2029,7 @@ export const codexHarness: Harness = {
1198
2029
  baseCmd: codexBaseCmd,
1199
2030
  sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
1200
2031
  sessionEnvVar: 'CODEX_THREAD_ID',
2032
+ launchEnv: noLaunchEnv,
1201
2033
  // Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
1202
2034
  // (codex-rs `root_checkout_hooks_folder_for_dir` rewrites the hooks-config folder to <repo_root>/<rel>/.codex
1203
2035
  // for any linked worktree). Every worktree's thread (cwd = worktree root) therefore reads the SAME
@@ -1252,12 +2084,228 @@ export const codexHarness: Harness = {
1252
2084
  if (pane?.pidAlive !== undefined) return pane.pidAlive ? 'online' : 'offline'
1253
2085
  return paneTreeRunsCodex(pane) ? 'online' : 'offline'
1254
2086
  },
2087
+ leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
1255
2088
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
1256
- cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
2089
+ cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
2090
+ coldRetirementPreflight: async (rec) => {
2091
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2092
+ const threadId = rec.harnessSessionId
2093
+ const dir = runtimeRoot()
2094
+ const generationBefore = codexRuntimeGeneration(dir)
2095
+ if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
2096
+ const result = await codexColdPreflight(threadId, dir, generationBefore)
2097
+ if (codexRuntimeGeneration(dir) !== generationBefore)
2098
+ return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
2099
+ if (!result.ok) return result
2100
+ if (!result.alreadyCold)
2101
+ return { ok: false, reason: `Codex target subtree ${result.receipt.activeIds.join(', ')} is not fully archived` }
2102
+ return { ok: true, alreadyCold: true }
2103
+ },
2104
+ coldPreflight: async (rec) => {
2105
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2106
+ return codexColdPreflight(rec.harnessSessionId)
2107
+ },
2108
+ coldRuntime: async (rec, suppliedReceipt) => {
2109
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2110
+ const threadId = rec.harnessSessionId
2111
+ const dir = runtimeRoot()
2112
+ const sock = codexAppServerSock(dir)
2113
+ const generationBefore = codexRuntimeGeneration(dir)
2114
+ if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
2115
+ if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
2116
+ return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
2117
+ const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
2118
+ if (frozenPlan && frozenPlan.generation !== generationBefore)
2119
+ return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
2120
+ const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore)
2121
+ if (!preflight.ok) return preflight
2122
+ const plan = frozenPlan ?? preflight.receipt
2123
+ if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
2124
+ !sameParentEdges(frozenPlan.parentEdges, preflight.receipt.parentEdges) ||
2125
+ !sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
2126
+ !sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
2127
+ return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
2128
+ if (codexRuntimeGeneration(dir) !== plan.generation)
2129
+ return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
2130
+ if (plan.activeIds.length === 0) return { ok: true }
2131
+ const subtreeSet = new Set(plan.subtreeIds)
2132
+ const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
2133
+ const fence = { dir, generation: plan.generation }
2134
+
2135
+ const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
2136
+ const restored = await codexRestoreColdPlan(plan, dir)
2137
+ return { ok: false, reason: restored.ok ? reason : `${reason}; ${restored.reason}` }
2138
+ }
2139
+
2140
+ const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
2141
+ const after = await codexColdPreflight(threadId, dir, plan.generation)
2142
+ if (!after.ok) return after
2143
+ if (codexRuntimeGeneration(dir) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
2144
+ if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
2145
+ return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
2146
+ if (after.receipt.activeIds.length)
2147
+ return { ok: false, reason: `Codex target subtree remains in the active collection (${after.receipt.activeIds.join(', ')})` }
2148
+ if (!sameIdSet(plan.subtreeIds, after.receipt.archivedIds))
2149
+ return { ok: false, reason: 'Codex target subtree is not uniquely archived after cold teardown' }
2150
+ const afterIds = new Set(after.receipt.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId)))
2151
+ if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
2152
+ return { ok: true }
2153
+ }
2154
+ for (const id of plan.activeIds) {
2155
+ const archived = await codexThreadMutation(sock, 'thread/archive', id, fence)
2156
+ if (!archived.ok) return compensate(`${archived.error} while archiving Codex subtree member ${id}`)
2157
+ }
2158
+ let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
2159
+ const verifyDeadline = Date.now() + 30_000
2160
+ for (let attempt = 0; attempt < 6 && Date.now() < verifyDeadline; attempt++) {
2161
+ verified = await coldCheck()
2162
+ if (verified.ok) break
2163
+ if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
2164
+ }
2165
+ if (verified.ok) return verified
2166
+ return compensate(verified.reason)
2167
+ },
2168
+ restoreRuntime: async (rec, suppliedReceipt) => {
2169
+ if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2170
+ if (suppliedReceipt !== undefined) {
2171
+ if (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== rec.harnessSessionId)
2172
+ return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
2173
+ return codexRestoreColdPlan(suppliedReceipt)
2174
+ }
2175
+ const sock = codexAppServerSock(runtimeRoot())
2176
+ const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
2177
+ const [active, archived] = await Promise.all([
2178
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
2179
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
2180
+ ])
2181
+ if (!active.ok || !archived.ok) return { ok: false, reason: 'Codex restore state could not be reconciled' }
2182
+ const inActive = active.ids.includes(rec.harnessSessionId!)
2183
+ const inArchived = archived.ids.includes(rec.harnessSessionId!)
2184
+ if (inActive && !inArchived) return { ok: true }
2185
+ if (inArchived && !inActive) return { ok: false, reason: 'Codex thread remains archived; restore can be retried' }
2186
+ return { ok: false, reason: 'Codex restore state is ambiguous (thread in both or neither collection)' }
2187
+ }
2188
+ const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId)
2189
+ if (!restored.ok) return reconcile()
2190
+ return reconcile()
2191
+ },
2192
+ sharedRuntimes: (runtimeDir) => [{
2193
+ key: 'codex-app-server',
2194
+ label: 'Codex app-server',
2195
+ pidFile: codexAppServerPid(runtimeDir),
2196
+ receiptFile: codexAppServerReceipt(runtimeDir),
2197
+ residency: async () => {
2198
+ const sock = codexAppServerSock(runtimeDir)
2199
+ let pid = 0
2200
+ try { pid = Number(readFileSync(codexAppServerPid(runtimeDir), 'utf8').trim()) } catch { /* stale/missing pid */ }
2201
+ const pidLive = pid > 0 && !!processStartToken(pid)
2202
+ const listener = await listenerAt(sock, 800)
2203
+ if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
2204
+ if (pidLive && (!codexRuntimeGeneration(runtimeDir) || listener !== 'live'))
2205
+ return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
2206
+ if (!pidLive || listener !== 'live')
2207
+ return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
2208
+ const result = await codexLoadedReferenceIds(sock)
2209
+ return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
2210
+ },
2211
+ mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts),
2212
+ probe: () => codexSharedRuntimeProbe(runtimeDir),
2213
+ }],
1257
2214
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
1258
2215
  // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
1259
2216
  // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
1260
2217
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
2218
+ // codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
2219
+ // that says so has already decided. (Its transient sibling — the rollout still being written — is handled
2220
+ // BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
2221
+ fatalLaunchOutput: ['no rollout found for thread id'],
2222
+ }
2223
+
2224
+ type CodexHeadlessLaunchReadinessProof = Readonly<{
2225
+ kind: 'codex-headless-shared-runtime'
2226
+ descriptorKey: string
2227
+ generation: CodexRuntimeGenerationProof
2228
+ target: Readonly<{
2229
+ sessionId: string
2230
+ threadId: string
2231
+ ownerSessionId: string
2232
+ ownerCount: 1
2233
+ ownerState: 'governed'
2234
+ referenceState: 'loaded'
2235
+ protectsControlPlane: true
2236
+ }>
2237
+ }>
2238
+
2239
+ const sameCodexHeadlessReadinessProof = (left: CodexHeadlessLaunchReadinessProof, right: CodexHeadlessLaunchReadinessProof) =>
2240
+ left.kind === right.kind &&
2241
+ left.descriptorKey === right.descriptorKey &&
2242
+ codexRuntimeGenerationToken(left.generation) === codexRuntimeGenerationToken(right.generation) &&
2243
+ left.target.sessionId === right.target.sessionId &&
2244
+ left.target.threadId === right.target.threadId &&
2245
+ left.target.ownerSessionId === right.target.ownerSessionId &&
2246
+ left.target.ownerCount === right.target.ownerCount &&
2247
+ left.target.ownerState === right.target.ownerState &&
2248
+ left.target.referenceState === right.target.referenceState &&
2249
+ left.target.protectsControlPlane === right.target.protectsControlPlane
2250
+
2251
+ const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string): string[] | null => {
2252
+ const root = join(runtimeDir, 'sessions')
2253
+ let entries
2254
+ try { entries = readdirSync(root, { withFileTypes: true }) }
2255
+ catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null }
2256
+ const owners: string[] = []
2257
+ for (const entry of entries) {
2258
+ if (!entry.isDirectory()) continue
2259
+ let parsed: unknown
2260
+ try { parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8')) }
2261
+ catch (error) {
2262
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
2263
+ return null
2264
+ }
2265
+ if (!parsed || typeof parsed !== 'object') return null
2266
+ const record = parsed as { session_id?: unknown; governed?: unknown; harness?: unknown; harness_session_id?: unknown }
2267
+ if (typeof record.session_id !== 'string') return null
2268
+ if (record.governed !== true) continue
2269
+ const harnessId = typeof record.harness === 'string' && record.harness ? record.harness : defaultHarness.id
2270
+ let sharesDescriptor = false
2271
+ try { sharesDescriptor = (harnessById(harnessId).sharedRuntimes?.(runtimeDir) ?? []).some((descriptor) => descriptor.key === descriptorKey) }
2272
+ catch { return null }
2273
+ if (sharesDescriptor && record.harness_session_id === threadId) owners.push(record.session_id)
2274
+ }
2275
+ return owners
2276
+ }
2277
+
2278
+ async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyRecord | null): Promise<CodexHeadlessLaunchReadinessProof | null> {
2279
+ const record = current()
2280
+ if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId) return null
2281
+ const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
2282
+ .find((candidate) => candidate.key === 'codex-app-server')
2283
+ if (!descriptor?.residency) return null
2284
+ const generationBefore = codexRuntimeGenerationProof(record.runtimeDir)
2285
+ if (!generationBefore) return null
2286
+ let resident: Awaited<ReturnType<NonNullable<SharedRuntimeDescriptor['residency']>>>
2287
+ try { resident = await descriptor.residency() }
2288
+ catch { return null }
2289
+ if (!resident.healthy) return null
2290
+ if (!resident.referenceIds.includes(record.harnessSessionId)) return null
2291
+ const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId)
2292
+ if (!owners || owners.length !== 1 || owners[0] !== record.session) return null
2293
+ const generationAfter = codexRuntimeGenerationProof(record.runtimeDir)
2294
+ if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter)) return null
2295
+ return Object.freeze({
2296
+ kind: 'codex-headless-shared-runtime',
2297
+ descriptorKey: descriptor.key,
2298
+ generation: generationAfter,
2299
+ target: Object.freeze({
2300
+ sessionId: record.session,
2301
+ threadId: record.harnessSessionId,
2302
+ ownerSessionId: owners[0],
2303
+ ownerCount: 1,
2304
+ ownerState: 'governed',
2305
+ referenceState: 'loaded',
2306
+ protectsControlPlane: true,
2307
+ }),
2308
+ })
1261
2309
  }
1262
2310
 
1263
2311
  // Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
@@ -1267,12 +2315,27 @@ export const codexHeadlessHarness: Harness = {
1267
2315
  ...codexHarness,
1268
2316
  id: 'codex-headless',
1269
2317
  headless: true,
2318
+ runtimeOwnership: 'adapter',
1270
2319
  launchOneShot: true,
1271
- messageStream: false,
1272
2320
  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',
2321
+ // Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
2322
+ // offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
2323
+ liveness: recordOnline,
2324
+ launchReady: async (current, deadline) => {
2325
+ for (;;) {
2326
+ const proof = await codexHeadlessReadinessProof(current)
2327
+ if (proof) return {
2328
+ proof,
2329
+ validate: async (latest) => {
2330
+ const currentProof = await codexHeadlessReadinessProof(latest)
2331
+ return !!currentProof && sameCodexHeadlessReadinessProof(proof, currentProof)
2332
+ },
2333
+ }
2334
+ const remaining = deadline - Date.now()
2335
+ if (remaining <= 0) return null
2336
+ await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)))
2337
+ }
2338
+ },
1276
2339
  // There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
1277
2340
  // runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
1278
2341
  resumeArg: () => '',
@@ -1283,8 +2346,8 @@ export const codexHeadlessHarness: Harness = {
1283
2346
  // lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
1284
2347
  // hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
1285
2348
  // 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
2349
+ // dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
2350
+ // CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
1288
2351
  // deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
1289
2352
  // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
1290
2353
  // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
@@ -1292,7 +2355,6 @@ export const codexHeadlessHarness: Harness = {
1292
2355
  export const piHarness: Harness = {
1293
2356
  id: 'pi',
1294
2357
  headless: false,
1295
- messageStream: false,
1296
2358
  events: PI_EVENTS,
1297
2359
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
1298
2360
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
@@ -1300,6 +2362,7 @@ export const piHarness: Harness = {
1300
2362
  baseCmd: piBaseCmd,
1301
2363
  sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
1302
2364
  sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
2365
+ launchEnv: rendezvousLaunchEnv,
1303
2366
  shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
1304
2367
  worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
1305
2368
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
@@ -1315,9 +2378,11 @@ export const piHarness: Harness = {
1315
2378
  slashCommands: piSlashCommands,
1316
2379
  // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
1317
2380
  // socket the generated extension binds. socketLive is already probed for every windowed session.
1318
- liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
2381
+ liveness: socketListenerLiveness,
2382
+ leafOwnerNeedle: (rec) => rec.session,
1319
2383
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1320
- cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
2384
+ cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2385
+ coldRuntime: async () => ({ ok: true }),
1321
2386
  // reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
1322
2387
  // LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
1323
2388
  resumeArg: (rec) => `--session ${rec.session}`,
@@ -1331,15 +2396,13 @@ export const piHeadlessHarness: Harness = {
1331
2396
  ...piHarness,
1332
2397
  id: 'pi-headless',
1333
2398
  headless: true,
1334
- messageStream: false,
2399
+ runtimeOwnership: 'adapter',
1335
2400
  paneTitleIsSelfSummary: false,
1336
2401
  launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
1337
- liveness: () => 'online',
2402
+ liveness: recordOnline,
1338
2403
  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
- },
2404
+ cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
2405
+ coldRuntime: async () => ({ ok: false, reason: 'pi-headless has no exact resident unload verification' }),
1343
2406
  deliveryBlockedBy: undefined,
1344
2407
  resumeArg: (rec) => `--session ${rec.session}`,
1345
2408
  }
@@ -1347,7 +2410,6 @@ export const piHeadlessHarness: Harness = {
1347
2410
  export const opencodeHarness: Harness = {
1348
2411
  id: 'opencode',
1349
2412
  headless: false,
1350
- messageStream: false,
1351
2413
  events: OPENCODE_EVENTS,
1352
2414
  // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
1353
2415
  // socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
@@ -1362,6 +2424,7 @@ export const opencodeHarness: Harness = {
1362
2424
  // (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
1363
2425
  // SPEXCODE_SESSION_ID tier resolves the record.
1364
2426
  sessionEnvVar: 'OPENCODE_SESSION_ID',
2427
+ launchEnv: rendezvousLaunchEnv,
1365
2428
  // the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
1366
2429
  // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
1367
2430
  shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
@@ -1379,10 +2442,11 @@ export const opencodeHarness: Harness = {
1379
2442
  // online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
1380
2443
  // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
1381
2444
  // 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'),
2445
+ liveness: socketListenerOrPidAliveLiveness,
2446
+ leafOwnerNeedle: (rec) => rec.session,
1384
2447
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1385
- cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
2448
+ cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2449
+ coldRuntime: async () => ({ ok: true }),
1386
2450
  // owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
1387
2451
  // SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
1388
2452
  // which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
@@ -1396,21 +2460,19 @@ export const opencodeHeadlessHarness: Harness = {
1396
2460
  ...opencodeHarness,
1397
2461
  id: 'opencode-headless',
1398
2462
  headless: true,
1399
- messageStream: false,
2463
+ runtimeOwnership: 'adapter',
1400
2464
  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',
2465
+ // A sleeping native conversation is still addressable by its non-stopped record. Transport breakage belongs
2466
+ // to the next delivery, where the live rendezvous or pane wake reports it loudly.
2467
+ liveness: recordOnline,
2468
+ coldRuntime: async () => ({ ok: false, reason: 'opencode-headless has no exact resident unload verification' }),
1404
2469
  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))
2470
+ return deliverViaSocketOrWake(
2471
+ rec.session,
2472
+ text,
2473
+ () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)),
2474
+ `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
2475
+ )
1414
2476
  },
1415
2477
  }
1416
2478