spexcode 0.5.3 → 0.5.5

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.
@@ -16,7 +16,7 @@ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from '.
16
16
  import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
17
17
  import { git } from './git.js'
18
18
  import { shQuote } from './sh.js'
19
- import { processStartToken, processTopology } from './process-identity.js'
19
+ import { detachedRuntimeGenerationToken, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
20
20
 
21
21
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
22
22
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -37,6 +37,8 @@ export type HarnessLaunchReadinessFence = {
37
37
  readonly proof: Readonly<Record<string, unknown>>
38
38
  validate(current: () => HarnessLaunchReadyRecord | null): Promise<boolean>
39
39
  }
40
+ export type TurnFailure = { message: string; completedAt: number | null }
41
+ export type FailureSubscription = { close(): void; readonly closed: Promise<string | null> }
40
42
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
41
43
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
42
44
  // one whole-box pid→(ppid, comm) table (a single `ps` spawn).
@@ -53,13 +55,13 @@ export type SharedRuntimeDescriptor = {
53
55
  key: string
54
56
  label: string
55
57
  pidFile: string
56
- isolationFile: string
58
+ receiptFile: string
57
59
  // Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
58
60
  // per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
59
61
  residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
60
62
  // Lifecycle mutation guard is deliberately narrower than the full resource projection: census every loaded
61
63
  // ID, but read only the exact governed target when it is loaded, plus both target descendant collections.
62
- mutationGuard?: (targetReferenceId: string) => Promise<SharedRuntimeMutationGuard>
64
+ mutationGuard?: (targetReferenceId: string, opts?: { coldReceipt?: unknown }) => Promise<SharedRuntimeMutationGuard>
63
65
  probe(): Promise<SharedRuntimeProbe>
64
66
  }
65
67
  export type SharedRuntimeMutationGuard = {
@@ -67,6 +69,7 @@ export type SharedRuntimeMutationGuard = {
67
69
  referenceIds: string[]
68
70
  targetTurnPresence: 'none' | 'idle' | 'active' | 'unknown'
69
71
  descendantIds: string[]
72
+ coldTeardownAuthorized?: boolean
70
73
  error?: string
71
74
  }
72
75
  export type SharedRuntimeProbe = {
@@ -79,6 +82,10 @@ export type SharedRuntimeProbe = {
79
82
  error?: string
80
83
  }
81
84
 
85
+ export type HarnessColdPreflight =
86
+ | { ok: true; alreadyCold?: boolean; receipt?: unknown }
87
+ | { ok: false; reason: string }
88
+
82
89
  export type AdapterLoadedReferenceState = {
83
90
  healthy: boolean
84
91
  loaded: boolean
@@ -127,6 +134,8 @@ export async function adapterLoadedReferenceState(
127
134
 
128
135
  export interface Harness {
129
136
  readonly id: HarnessId
137
+ // the id baked into the materialized shim. Headless variants reuse their native family's shim.
138
+ readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
130
139
  // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
131
140
  // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
132
141
  readonly headless: boolean
@@ -187,6 +196,9 @@ export interface Harness {
187
196
  // --- materialize: shim + contract + trust ([[harness-delivery]]) ---
188
197
  // the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
189
198
  shimFile(proj: string): string
199
+ // whether that shim belongs to one checkout or the whole project. This is adapter placement data: Codex
200
+ // reads one root-checkout hook file for every linked tree; the other harnesses discover their tree-local file.
201
+ shimScope: 'tree' | 'project'
190
202
  // a LINKED WORKTREE's extra shim copy — the worktree-side `.codex` hook file that ANCHORS codex's project
191
203
  // config layer, or null when the harness needs none. codex-rs only builds a project config layer (and thus
192
204
  // only DISCOVERS a worktree thread's hooks) for a dir in [cwd..project_root] that contains a `.codex/`
@@ -262,6 +274,9 @@ export interface Harness {
262
274
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
263
275
  // Returns ok=false with a reason that propagates to the API.
264
276
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
277
+ // Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
278
+ // transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
279
+ observeTurnFailures?(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription
265
280
  // Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
266
281
  // without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
267
282
  interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
@@ -273,14 +288,16 @@ export interface Harness {
273
288
  cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
274
289
  // Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
275
290
  // unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
276
- coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold?: boolean } | { ok: false; reason: string }>
291
+ // Its optional receipt is opaque adapter authority: product code may only pass the same object back to the
292
+ // stop guard and coldRuntime, never inspect it or synthesize a recursive/archive mode.
293
+ coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<HarnessColdPreflight>
277
294
  // A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
278
295
  // this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
279
296
  coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
280
297
  // Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
281
298
  // reference or return a loud reason; adapters without such a resident reference return {ok:true}.
282
- coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
283
- restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
299
+ coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
300
+ restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
284
301
  // Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
285
302
  // references and protect a sibling-owned control plane without learning harness command names.
286
303
  sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
@@ -297,7 +314,7 @@ export interface Harness {
297
314
  // skill/agent files named in `arts` — never the user's surrounding prose, their other settings, or any .spec
298
315
  // data. materialize calls it for every UNSELECTED harness, so dropping a harness from spexcode.json's
299
316
  // `harnesses` prunes that harness's products on the next re-materialize.
300
- clean(proj: string, arts: HarnessArtifacts): void
317
+ clean(proj: string, arts: HarnessArtifacts, preserveProject?: boolean): void
301
318
  // the inverse of writeTrust: strip THIS project's spexcode trust block from the harness's global config.
302
319
  // Codex removes its `~/.codex/config.toml` block; Claude is a no-op (it wrote none).
303
320
  removeTrust(proj: string): void
@@ -422,37 +439,26 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
422
439
  return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
423
440
  }
424
441
  export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
425
- export const codexAppServerIsolation = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope')
442
+ export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
426
443
  type CodexRuntimeGenerationProof = Readonly<{
427
- pid: number
428
- startToken: string
429
- processGroupId: number
430
- sessionId: number
431
- isolation: string
444
+ identity: VerifiedDetachedRuntime
432
445
  socket: Readonly<{ path: string; dev: number; ino: number }>
433
446
  }>
434
447
  function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
435
448
  try {
436
449
  const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
437
- const start = processStartToken(pid)
438
- const scope = readFileSync(codexAppServerIsolation(dir), 'utf8').trim()
439
- const topology = processTopology(pid)
450
+ const detached = verifyDetachedRuntime(pid, codexAppServerReceipt(dir))
440
451
  const socketPath = codexAppServerSock(dir)
441
452
  const socket = statSync(socketPath)
442
- if (!(pid > 0) || !start || !topology || topology.startToken !== start || topology.processGroupId !== pid || topology.sessionId !== pid ||
443
- scope !== `detached-v3 ${pid} ${start} ${pid} ${pid}` || !socket.isSocket()) return null
453
+ if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
444
454
  return Object.freeze({
445
- pid,
446
- startToken: start,
447
- processGroupId: topology.processGroupId,
448
- sessionId: topology.sessionId,
449
- isolation: scope,
455
+ identity: detached.identity,
450
456
  socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
451
457
  })
452
458
  } catch { return null }
453
459
  }
454
460
  const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
455
- `${proof.pid}|${proof.startToken}|${proof.processGroupId}|${proof.sessionId}|${proof.isolation}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
461
+ `${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
456
462
  function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
457
463
  const proof = codexRuntimeGenerationProof(dir)
458
464
  return proof ? codexRuntimeGenerationToken(proof) : null
@@ -673,14 +679,14 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
673
679
  const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
674
680
  const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
675
681
  const pid = codexAppServerPid(dir)
676
- const isolation = codexAppServerIsolation(dir)
682
+ const receipt = codexAppServerReceipt(dir)
677
683
  const log = join(dir, 'codex-app-server.log')
678
684
  const lock = join(dir, 'codex-app-server.lock')
679
685
  const script = [
680
686
  `dir=${shQuote(dir)}`,
681
687
  `sock=${shQuote(sock)}`,
682
688
  `pid=${shQuote(pid)}`,
683
- `isolation=${shQuote(isolation)}`,
689
+ `receipt=${shQuote(receipt)}`,
684
690
  `log=${shQuote(log)}`,
685
691
  `lock=${shQuote(lock)}`,
686
692
  // codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
@@ -724,10 +730,10 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
724
730
  // mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
725
731
  // inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
726
732
  // The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
727
- // a boundary: the Codex Node launcher reset signal handling and died with the tmux pane despite a matching
728
- // stamp. The internal helper uses child_process detached=true, records PID/start plus the observed pgrp/sid,
729
- // and refuses unless the live process is its own session leader. The stop guard re-reads that topology.
730
- ` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$isolation" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
733
+ // a boundary: the Codex Node launcher reset signal handling and died with the tmux pane. The internal helper
734
+ // uses child_process detached=true, then the process adapter publishes a private receipt only after proving
735
+ // exact PID/start + PGID (and Linux SID). Every consumer re-verifies that same receipt.
736
+ ` ( 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; }`,
731
737
  ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
732
738
  'fi',
733
739
  'rmdir "$lockd" 2>/dev/null',
@@ -820,6 +826,106 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
820
826
  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`
821
827
  const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
822
828
 
829
+ // Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
830
+ // with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
831
+ // this observer also survives backend replacement; a thread already in systemError is reconciled from its
832
+ // latest turn before later live notifications take over.
833
+ export function codexTurnFailureObserver(
834
+ rec: HarnessDeliveryRecord,
835
+ onFailure: (failure: TurnFailure) => void,
836
+ ): FailureSubscription {
837
+ const threadId = rec.harnessSessionId
838
+ if (!threadId) return { close: () => {}, closed: Promise.resolve(null) }
839
+ const sock = codexAppServerSock(rec.runtimeDir || runtimeRoot())
840
+ const conn: Socket = createConnection(sock)
841
+ const frames: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
842
+ let upgraded = false, settled = false
843
+ let reconciliationTimer: ReturnType<typeof setTimeout> | null = null
844
+ let resolveClosed!: (reason: string | null) => void
845
+ const closed = new Promise<string | null>((resolve) => { resolveClosed = resolve })
846
+ const cancelReconciliation = () => {
847
+ if (!reconciliationTimer) return
848
+ clearTimeout(reconciliationTimer)
849
+ reconciliationTimer = null
850
+ }
851
+ const finish = (reason: string | null) => {
852
+ if (settled) return
853
+ settled = true
854
+ clearTimeout(timer)
855
+ cancelReconciliation()
856
+ try { conn.destroy() } catch {}
857
+ resolveClosed(reason)
858
+ }
859
+ const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000)
860
+ timer.unref?.()
861
+ const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
862
+ const report = (turn: unknown, fallbackMessage?: string) => {
863
+ const value = turn as { status?: unknown; completedAt?: unknown; error?: { message?: unknown } | null }
864
+ if (value?.status !== 'failed' && !fallbackMessage) return
865
+ const nativeMessage = typeof value?.error?.message === 'string' ? value.error.message.trim() : ''
866
+ onFailure({
867
+ message: nativeMessage || fallbackMessage || 'Codex turn failed',
868
+ completedAt: typeof value?.completedAt === 'number' && Number.isFinite(value.completedAt) ? value.completedAt : null,
869
+ })
870
+ }
871
+ conn.on('error', (error) => finish(`Codex turn observer connection failed: ${rpcError(error)}`))
872
+ conn.on('close', () => finish('Codex turn observer connection closed'))
873
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
874
+ const handle = (json: string) => {
875
+ let message: JsonRpc
876
+ try { message = JSON.parse(json) } catch { return }
877
+ if (message.error) return finish(`Codex turn observer request failed: ${message.error.message || JSON.stringify(message.error)}`)
878
+ if (message.id === 1 && message.result) {
879
+ send({ method: 'initialized', params: {} })
880
+ return send({
881
+ id: 2,
882
+ method: 'thread/resume',
883
+ params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } },
884
+ })
885
+ }
886
+ if (message.id === 2 && message.result) {
887
+ clearTimeout(timer)
888
+ const result = message.result as { thread?: { status?: { type?: unknown } }; initialTurnsPage?: { data?: unknown } }
889
+ if (result.thread?.status?.type === 'systemError') {
890
+ const turns = result.initialTurnsPage?.data
891
+ const latest = Array.isArray(turns) ? turns[0] : null
892
+ // Give a concurrently-starting turn's native notification precedence over this historical snapshot.
893
+ reconciliationTimer = setTimeout(() => {
894
+ reconciliationTimer = null
895
+ report(latest, 'Codex thread entered systemError before the turn observer subscribed')
896
+ }, 100)
897
+ reconciliationTimer.unref?.()
898
+ }
899
+ return
900
+ }
901
+ if (message.method === 'turn/started') {
902
+ const params = message.params as { threadId?: unknown } | undefined
903
+ if (params?.threadId === threadId) cancelReconciliation()
904
+ }
905
+ if (message.method === 'turn/completed') {
906
+ const params = message.params as { threadId?: unknown; turn?: unknown } | undefined
907
+ if (params?.threadId === threadId) {
908
+ cancelReconciliation()
909
+ report(params.turn)
910
+ }
911
+ }
912
+ }
913
+ conn.on('data', (chunk: Buffer) => {
914
+ frames.buf = Buffer.concat([frames.buf, chunk])
915
+ if (!upgraded) {
916
+ const split = frames.buf.indexOf('\r\n\r\n')
917
+ if (split < 0) return
918
+ const head = frames.buf.slice(0, split).toString('utf8')
919
+ if (!/^HTTP\/1\.1 101/.test(head)) return finish(`Codex app-server refused turn observer: ${head.split('\r\n')[0]}`)
920
+ upgraded = true
921
+ frames.buf = frames.buf.slice(split + 4)
922
+ send(wsInitialize)
923
+ }
924
+ if (drainWsFrames(frames, conn, handle)) finish('Codex app-server closed the turn observer')
925
+ })
926
+ return { close: () => finish(null), closed }
927
+ }
928
+
823
929
  // Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
824
930
  // defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
825
931
  type CodexGenerationFence = { dir: string; generation: string }
@@ -890,7 +996,14 @@ export const CODEX_THREAD_SOURCE_KINDS = [
890
996
  'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
891
997
  'subAgentThreadSpawn', 'subAgentOther', 'unknown',
892
998
  ] as const
893
- function codexPagedIds(sock: string, method: 'thread/list' | 'thread/loaded/list', params: Record<string, unknown>, extractId: (item: unknown) => string | null, label: string): Promise<CodexPagedIdsResult> {
999
+ function codexPagedIds(
1000
+ sock: string,
1001
+ method: 'thread/list' | 'thread/loaded/list',
1002
+ params: Record<string, unknown>,
1003
+ extractId: (item: unknown) => string | null,
1004
+ label: string,
1005
+ onItem?: (item: unknown) => void,
1006
+ ): Promise<CodexPagedIdsResult> {
894
1007
  return new Promise((resolve) => {
895
1008
  const conn: Socket = createConnection(sock)
896
1009
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
@@ -914,6 +1027,7 @@ function codexPagedIds(sock: string, method: 'thread/list' | 'thread/loaded/list
914
1027
  if (message.id !== requestId || !message.result) return
915
1028
  const page = message.result as { data?: unknown; nextCursor?: unknown }
916
1029
  if (Array.isArray(page.data)) for (const item of page.data) {
1030
+ onItem?.(item)
917
1031
  const id = extractId(item)
918
1032
  if (typeof id === 'string') ids.add(id)
919
1033
  }
@@ -992,14 +1106,35 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
992
1106
  // The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
993
1107
  // a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
994
1108
  export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
1109
+ return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
1110
+ }
1111
+
1112
+ type CodexThreadCollectionResult =
1113
+ | { ok: true; ids: string[]; parentById: Map<string, string | null> }
1114
+ | { ok: false; error: string }
1115
+
1116
+ function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
995
1117
  const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
996
1118
  ? params.sourceKinds
997
1119
  : [...CODEX_THREAD_SOURCE_KINDS]
1120
+ const parentById = new Map<string, string | null>()
1121
+ const conflictingParents = new Set<string>()
998
1122
  return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
999
1123
  if (typeof item === 'string') return item
1000
1124
  const id = (item as { id?: unknown } | null)?.id
1001
1125
  return typeof id === 'string' ? id : null
1002
- }, 'thread/list')
1126
+ }, 'thread/list', (item) => {
1127
+ if (!item || typeof item !== 'object') return
1128
+ const row = item as { id?: unknown; parentThreadId?: unknown }
1129
+ if (typeof row.id !== 'string') return
1130
+ const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
1131
+ if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
1132
+ parentById.set(row.id, parent)
1133
+ }).then((result) => {
1134
+ if (!result.ok) return result
1135
+ if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
1136
+ return { ...result, parentById }
1137
+ })
1003
1138
  }
1004
1139
 
1005
1140
  async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
@@ -1029,28 +1164,194 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()):
1029
1164
  return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
1030
1165
  }
1031
1166
 
1032
- type CodexColdPreflight = { ok: true; alreadyCold?: boolean; guard: SharedRuntimeMutationGuard } | { ok: false; reason: string }
1033
- async function codexColdPreflight(threadId: string, dir = runtimeRoot()): Promise<CodexColdPreflight> {
1167
+ const CODEX_COLD_PLAN = Symbol('codex-cold-plan')
1168
+ type CodexColdPlan = Readonly<{
1169
+ [CODEX_COLD_PLAN]: true
1170
+ kind: 'codex-cold-subtree-v1'
1171
+ threadId: string
1172
+ generation: string
1173
+ guard: SharedRuntimeMutationGuard
1174
+ descendantIds: readonly string[]
1175
+ parentEdges: readonly (readonly [string, string])[]
1176
+ subtreeIds: readonly string[]
1177
+ activeIds: readonly string[]
1178
+ archivedIds: readonly string[]
1179
+ }>
1180
+ type CodexColdPreflight = { ok: true; alreadyCold?: boolean; receipt: CodexColdPlan } | { ok: false; reason: string }
1181
+
1182
+ const sameIdSet = (left: readonly string[], right: readonly string[]) =>
1183
+ left.length === right.length && left.every((id) => right.includes(id))
1184
+
1185
+ const sameParentEdges = (left: readonly (readonly [string, string])[], right: readonly (readonly [string, string])[]) =>
1186
+ left.length === right.length && left.every(([id, parent]) => right.some(([otherId, otherParent]) => id === otherId && parent === otherParent))
1187
+
1188
+ const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
1189
+ if (!value || typeof value !== 'object') return false
1190
+ const plan = value as Partial<CodexColdPlan>
1191
+ return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
1192
+ typeof plan.generation === 'string' && Array.isArray(plan.descendantIds) &&
1193
+ Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
1194
+ Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
1195
+ }
1196
+
1197
+ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
1198
+ const generation = expectedGeneration ?? codexRuntimeGeneration(dir)
1199
+ if (!generation || codexRuntimeGeneration(dir) !== generation)
1200
+ return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
1034
1201
  const sock = codexAppServerSock(dir)
1035
- const [guard, archivedList, activeList] = await Promise.all([
1036
- codexTargetMutationGuard(threadId, dir),
1202
+ const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
1203
+ codexLoadedReferenceIds(sock),
1204
+ codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1205
+ codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1037
1206
  codexThreadList(sock, { archived: true, sourceKinds: [] }),
1038
1207
  codexThreadList(sock, { archived: false, sourceKinds: [] }),
1039
1208
  ])
1040
- if (guard.descendantIds.length)
1041
- return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); subtree archive is not proven safe` }
1042
- if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
1209
+ if (codexRuntimeGeneration(dir) !== generation)
1210
+ return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
1211
+ if (!loaded.ok) return { ok: false, reason: loaded.error }
1212
+ if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
1213
+ if (!archivedDescendants.ok) return { ok: false, reason: archivedDescendants.error }
1043
1214
  if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1044
1215
  if (!activeList.ok) return { ok: false, reason: activeList.error }
1045
- const inArchived = archivedList.ids.includes(threadId)
1046
- const inActive = activeList.ids.includes(threadId)
1047
- if (inArchived === inActive) return { ok: false, reason: `Codex thread ${threadId} has ambiguous disk collection state (archived=${inArchived}, active=${inActive})` }
1048
- if (guard.targetTurnPresence === 'active') return { ok: false, reason: `Codex thread ${threadId} has an active turn; terminal-state confirmation is required before archive` }
1049
- if (guard.targetTurnPresence === 'unknown') return { ok: false, reason: `Codex thread ${threadId} turn state is unknown; refusing to archive without terminal-state confirmation` }
1050
- if (guard.targetTurnPresence === 'none')
1051
- return inArchived ? { ok: true, alreadyCold: true, guard } : { ok: true, guard }
1052
- if (inArchived) return { ok: false, reason: `Codex thread ${threadId} is archived but remains loaded` }
1053
- return { ok: true, guard }
1216
+
1217
+ const activeDescendantSet = new Set(activeDescendants.ids)
1218
+ const archivedDescendantSet = new Set(archivedDescendants.ids)
1219
+ const duplicateDescendants = activeDescendants.ids.filter((id) => archivedDescendantSet.has(id))
1220
+ if (duplicateDescendants.length)
1221
+ return { ok: false, reason: `Codex subtree members occur in both active and archived descendant collections (${duplicateDescendants.join(', ')})` }
1222
+ const descendantIds = [...activeDescendants.ids, ...archivedDescendants.ids]
1223
+ if (descendantIds.includes(threadId)) return { ok: false, reason: `Codex target ${threadId} is duplicated in its own descendant closure` }
1224
+
1225
+ const parentById = new Map([...activeDescendants.parentById, ...archivedDescendants.parentById])
1226
+ const depthById = new Map<string, number>()
1227
+ for (const id of descendantIds) {
1228
+ const seen = new Set([id])
1229
+ let cursor = id
1230
+ let depth = 0
1231
+ while (cursor !== threadId) {
1232
+ const next = parentById.get(cursor)
1233
+ if (!next) return { ok: false, reason: `Codex descendant ${id} has no complete parent chain to target ${threadId} (unowned or reassigned)` }
1234
+ if (seen.has(next)) return { ok: false, reason: `Codex descendant ${id} has a cyclic parent chain` }
1235
+ seen.add(next)
1236
+ cursor = next
1237
+ depth++
1238
+ }
1239
+ depthById.set(id, depth)
1240
+ }
1241
+
1242
+ const activeSet = new Set(activeList.ids)
1243
+ const archivedSet = new Set(archivedList.ids)
1244
+ const subtreeIds = [...descendantIds, threadId]
1245
+ for (const id of subtreeIds) {
1246
+ const inActive = activeSet.has(id)
1247
+ const inArchived = archivedSet.has(id)
1248
+ if (!inActive && !inArchived)
1249
+ return { ok: false, reason: `Codex subtree member ${id} is absent from both native collections (unowned or reassigned)` }
1250
+ if (inActive && inArchived)
1251
+ return { ok: false, reason: `Codex subtree member ${id} occurs in both active and archived native collections` }
1252
+ if (id !== threadId) {
1253
+ const expectedActive = activeDescendantSet.has(id)
1254
+ if (inActive !== expectedActive)
1255
+ return { ok: false, reason: `Codex subtree member ${id} changed collection assignment during ownership census` }
1256
+ }
1257
+ }
1258
+
1259
+ const loadedSet = new Set(loaded.referenceIds)
1260
+ const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
1261
+ const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
1262
+ if (codexRuntimeGeneration(dir) !== generation)
1263
+ return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
1264
+ for (const { id, state } of turnStates) {
1265
+ if (!state.ok) return { ok: false, reason: state.error }
1266
+ if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
1267
+ if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
1268
+ if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
1269
+ }
1270
+
1271
+ const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
1272
+ const guard: SharedRuntimeMutationGuard = {
1273
+ healthy: true,
1274
+ referenceIds: [...loaded.referenceIds],
1275
+ targetTurnPresence,
1276
+ descendantIds: [...descendantIds],
1277
+ }
1278
+ const activeIds = [...activeDescendants.ids]
1279
+ .sort((left, right) => (depthById.get(right) ?? 0) - (depthById.get(left) ?? 0))
1280
+ .concat(activeSet.has(threadId) ? [threadId] : [])
1281
+ const archivedIds = [...archivedDescendants.ids, ...(archivedSet.has(threadId) ? [threadId] : [])]
1282
+ const parentEdges = descendantIds.map((id) => [id, parentById.get(id)!] as const)
1283
+ const receipt: CodexColdPlan = Object.freeze({
1284
+ [CODEX_COLD_PLAN]: true as const,
1285
+ kind: 'codex-cold-subtree-v1',
1286
+ threadId,
1287
+ generation,
1288
+ guard,
1289
+ descendantIds: Object.freeze([...descendantIds]),
1290
+ parentEdges: Object.freeze(parentEdges),
1291
+ subtreeIds: Object.freeze([...subtreeIds]),
1292
+ activeIds: Object.freeze(activeIds),
1293
+ archivedIds: Object.freeze(archivedIds),
1294
+ })
1295
+ return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
1296
+ }
1297
+
1298
+ async function codexMutationGuard(
1299
+ threadId: string,
1300
+ dir = runtimeRoot(),
1301
+ opts: { coldReceipt?: unknown } = {},
1302
+ ): Promise<SharedRuntimeMutationGuard> {
1303
+ if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir)
1304
+ if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
1305
+ return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
1306
+ const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation)
1307
+ if (!current.ok) {
1308
+ const guard = await codexTargetMutationGuard(threadId, dir)
1309
+ return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
1310
+ }
1311
+ const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
1312
+ sameParentEdges(opts.coldReceipt.parentEdges, current.receipt.parentEdges) &&
1313
+ sameIdSet(opts.coldReceipt.activeIds, current.receipt.activeIds) &&
1314
+ sameIdSet(opts.coldReceipt.archivedIds, current.receipt.archivedIds)
1315
+ return {
1316
+ ...current.receipt.guard,
1317
+ healthy: authorized,
1318
+ coldTeardownAuthorized: authorized,
1319
+ ...(authorized ? {} : { error: 'adapter cold teardown receipt no longer matches the target subtree' }),
1320
+ }
1321
+ }
1322
+
1323
+ async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
1324
+ if (codexRuntimeGeneration(dir) !== plan.generation)
1325
+ return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1326
+ const sock = codexAppServerSock(dir)
1327
+ const [activeBefore, archivedBefore] = await Promise.all([
1328
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1329
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1330
+ ])
1331
+ if (!activeBefore.ok || !archivedBefore.ok)
1332
+ return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
1333
+ if (codexRuntimeGeneration(dir) !== plan.generation)
1334
+ return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1335
+ const activeSet = new Set(activeBefore.ids)
1336
+ const archivedSet = new Set(archivedBefore.ids)
1337
+ if (plan.archivedIds.some((id) => !archivedSet.has(id) || activeSet.has(id)))
1338
+ return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
1339
+ if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
1340
+ return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
1341
+ const fence = { dir, generation: plan.generation }
1342
+ const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
1343
+ for (const id of restoreIds) {
1344
+ const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
1345
+ if (!restored.ok) return { ok: false, reason: `compensation failed for ${id}: ${restored.error}` }
1346
+ }
1347
+ const [activeAfter, archivedAfter] = await Promise.all([
1348
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1349
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1350
+ ])
1351
+ const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir) === plan.generation &&
1352
+ plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
1353
+ plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
1354
+ return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
1054
1355
  }
1055
1356
 
1056
1357
  // Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
@@ -1116,6 +1417,8 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
1116
1417
  const listener = await listenerAt(sock, 800)
1117
1418
  if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
1118
1419
  if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
1420
+ const generation = codexRuntimeGeneration(dir)
1421
+ if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
1119
1422
  return new Promise<SharedRuntimeProbe>((resolve) => {
1120
1423
  const conn: Socket = createConnection(sock)
1121
1424
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
@@ -1133,7 +1436,9 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
1133
1436
  settled = true
1134
1437
  clearTimeout(timer)
1135
1438
  try { conn.destroy() } catch { /* */ }
1136
- resolve(result)
1439
+ resolve(result.healthy && codexRuntimeGeneration(dir) !== generation
1440
+ ? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
1441
+ : result)
1137
1442
  }
1138
1443
  const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
1139
1444
  timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
@@ -1592,16 +1897,16 @@ function isTrackedFile(proj: string, f: string): boolean {
1592
1897
  // block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
1593
1898
  // blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
1594
1899
  // a sibling skill/agent the user added, and NEVER any .spec data.
1595
- function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
1900
+ function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts, preserveProject = false): void {
1596
1901
  // deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
1597
1902
  // that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
1598
1903
  // pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
1599
1904
  for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f))
1600
1905
  const shim = h.shimFile(proj)
1601
- if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
1906
+ if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
1602
1907
  const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
1603
1908
  if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh')) rmSync(anchor, { force: true })
1604
- h.removeTrust(proj)
1909
+ if (!preserveProject) h.removeTrust(proj)
1605
1910
  const sd = h.skillDir(proj)
1606
1911
  if (sd) for (const n of arts.skills) rmSync(join(sd, n), { recursive: true, force: true })
1607
1912
  const ad = h.agentDir(proj)
@@ -1758,6 +2063,7 @@ const noLaunchEnv = (): string[] => []
1758
2063
 
1759
2064
  export const claudeHarness: Harness = {
1760
2065
  id: 'claude',
2066
+ dispatchId: 'claude',
1761
2067
  headless: false,
1762
2068
  events: CLAUDE_EVENTS,
1763
2069
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
@@ -1768,6 +2074,7 @@ export const claudeHarness: Harness = {
1768
2074
  sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
1769
2075
  launchEnv: rendezvousLaunchEnv,
1770
2076
  shimFile: (proj) => join(proj, '.claude', 'settings.json'),
2077
+ shimScope: 'tree',
1771
2078
  worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
1772
2079
  contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
1773
2080
  skillDir: (proj) => join(proj, '.claude', 'skills'),
@@ -1775,7 +2082,7 @@ export const claudeHarness: Harness = {
1775
2082
  shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
1776
2083
  writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
1777
2084
  removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
1778
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2085
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
1779
2086
  slashCommands: claudeSlashCommands,
1780
2087
  // online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
1781
2088
  // the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
@@ -1825,6 +2132,7 @@ export const claudeHeadlessHarness: Harness = {
1825
2132
 
1826
2133
  export const codexHarness: Harness = {
1827
2134
  id: 'codex',
2135
+ dispatchId: 'codex',
1828
2136
  headless: false,
1829
2137
  sharedRuntimeSpawn: true,
1830
2138
  events: CODEX_EVENTS,
@@ -1843,6 +2151,7 @@ export const codexHarness: Harness = {
1843
2151
  // per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
1844
2152
  // cwd, so one shared shim serves every worktree.
1845
2153
  shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
2154
+ shimScope: 'project',
1846
2155
  // a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
1847
2156
  // the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
1848
2157
  // root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
@@ -1875,7 +2184,7 @@ export const codexHarness: Harness = {
1875
2184
  writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
1876
2185
  // trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
1877
2186
  removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
1878
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2187
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
1879
2188
  slashCommands: codexSlashCommands,
1880
2189
  // online iff the tmux window is up AND the agent is live. PRIMARY: the launch-registered `agent.pid` hot-tier
1881
2190
  // verdict (`pidAlive`) — a 100ms syscall (kill-0), no ps scan. LEGACY: a pre-registration session has no
@@ -1891,6 +2200,7 @@ export const codexHarness: Harness = {
1891
2200
  },
1892
2201
  leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
1893
2202
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
2203
+ observeTurnFailures: codexTurnFailureObserver,
1894
2204
  cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
1895
2205
  coldRetirementPreflight: async (rec) => {
1896
2206
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
@@ -1898,75 +2208,67 @@ export const codexHarness: Harness = {
1898
2208
  const dir = runtimeRoot()
1899
2209
  const generationBefore = codexRuntimeGeneration(dir)
1900
2210
  if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1901
- const sock = codexAppServerSock(dir)
1902
- const [guard, archivedList, activeList] = await Promise.all([
1903
- codexTargetMutationGuard(threadId, dir),
1904
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1905
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1906
- ])
2211
+ const result = await codexColdPreflight(threadId, dir, generationBefore)
1907
2212
  if (codexRuntimeGeneration(dir) !== generationBefore)
1908
2213
  return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
1909
- if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); cold retirement is not proven safe` }
1910
- if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
1911
- if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1912
- if (!activeList.ok) return { ok: false, reason: activeList.error }
1913
- if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} is still loaded` }
1914
- const inArchived = archivedList.ids.includes(threadId)
1915
- const inActive = activeList.ids.includes(threadId)
1916
- if (!inArchived || inActive) return { ok: false, reason: `Codex thread ${threadId} is not uniquely in the archived collection (archived=${inArchived}, active=${inActive})` }
2214
+ if (!result.ok) return result
2215
+ if (!result.alreadyCold)
2216
+ return { ok: false, reason: `Codex target subtree ${result.receipt.activeIds.join(', ')} is not fully archived` }
1917
2217
  return { ok: true, alreadyCold: true }
1918
2218
  },
1919
2219
  coldPreflight: async (rec) => {
1920
2220
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1921
- const result = await codexColdPreflight(rec.harnessSessionId)
1922
- return result.ok ? { ok: true, ...(result.alreadyCold ? { alreadyCold: true } : {}) } : result
2221
+ return codexColdPreflight(rec.harnessSessionId)
1923
2222
  },
1924
- coldRuntime: async (rec) => {
2223
+ coldRuntime: async (rec, suppliedReceipt) => {
1925
2224
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1926
2225
  const threadId = rec.harnessSessionId
1927
- const sock = codexAppServerSock(runtimeRoot())
1928
- const generationBefore = codexRuntimeGeneration(runtimeRoot())
2226
+ const dir = runtimeRoot()
2227
+ const sock = codexAppServerSock(dir)
2228
+ const generationBefore = codexRuntimeGeneration(dir)
1929
2229
  if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1930
- const preflight = await codexColdPreflight(threadId)
1931
- if (preflight && !preflight.ok) return preflight
1932
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during target guard' }
1933
- if (preflight?.ok && preflight.alreadyCold) return { ok: true }
1934
- const siblingBefore = preflight.guard.referenceIds.filter((referenceId) => referenceId !== threadId)
2230
+ if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
2231
+ return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
2232
+ const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
2233
+ if (frozenPlan && frozenPlan.generation !== generationBefore)
2234
+ return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
2235
+ const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore)
2236
+ if (!preflight.ok) return preflight
2237
+ const plan = frozenPlan ?? preflight.receipt
2238
+ if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
2239
+ !sameParentEdges(frozenPlan.parentEdges, preflight.receipt.parentEdges) ||
2240
+ !sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
2241
+ !sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
2242
+ return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
2243
+ if (codexRuntimeGeneration(dir) !== plan.generation)
2244
+ return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
2245
+ if (plan.activeIds.length === 0) return { ok: true }
2246
+ const subtreeSet = new Set(plan.subtreeIds)
2247
+ const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
2248
+ const fence = { dir, generation: plan.generation }
2249
+
2250
+ const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
2251
+ const restored = await codexRestoreColdPlan(plan, dir)
2252
+ return { ok: false, reason: restored.ok ? reason : `${reason}; ${restored.reason}` }
2253
+ }
2254
+
1935
2255
  const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
1936
- const [guard, archivedList, activeList] = await Promise.all([
1937
- codexTargetMutationGuard(threadId),
1938
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1939
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1940
- ])
1941
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
1942
- if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} acquired owned descendants during archive (${guard.descendantIds.join(', ')})` }
1943
- if (!guard.healthy) return { ok: false, reason: guard.error || 'Codex post-archive target guard is unhealthy' }
1944
- if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1945
- if (!activeList.ok) return { ok: false, reason: activeList.error }
1946
- if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} remains loaded after thread/archive` }
1947
- if (!archivedList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} is absent from both loaded and archived collections` }
1948
- if (activeList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} remains in the non-archived collection` }
1949
- const afterIds = new Set(guard.referenceIds.filter((referenceId) => referenceId !== threadId))
2256
+ const after = await codexColdPreflight(threadId, dir, plan.generation)
2257
+ if (!after.ok) return after
2258
+ if (codexRuntimeGeneration(dir) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
2259
+ if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
2260
+ return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
2261
+ if (after.receipt.activeIds.length)
2262
+ return { ok: false, reason: `Codex target subtree remains in the active collection (${after.receipt.activeIds.join(', ')})` }
2263
+ if (!sameIdSet(plan.subtreeIds, after.receipt.archivedIds))
2264
+ return { ok: false, reason: 'Codex target subtree is not uniquely archived after cold teardown' }
2265
+ const afterIds = new Set(after.receipt.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId)))
1950
2266
  if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
1951
2267
  return { ok: true }
1952
2268
  }
1953
- const fence = { dir: runtimeRoot(), generation: generationBefore }
1954
- const archived = await codexThreadMutation(sock, 'thread/archive', rec.harnessSessionId, fence)
1955
- if (!archived.ok) {
1956
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
1957
- return { ok: false, reason: `${archived.error}; shared Codex app-server generation changed, so archive state is unknown and no compensation was attempted` }
1958
- // RPC transport failure is commit-unknown. Reconcile collections before deciding whether compensation is needed.
1959
- const [archivedList, activeList] = await Promise.all([
1960
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1961
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1962
- ])
1963
- if (archivedList.ok && archivedList.ids.includes(rec.harnessSessionId)) {
1964
- const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
1965
- const stillActive = await codexThreadList(sock, { archived: false, sourceKinds: [] })
1966
- const suffix = restored.ok && stillActive.ok && stillActive.ids.includes(rec.harnessSessionId) ? '' : '; compensation/reconciliation failed'
1967
- return { ok: false, reason: `${archived.error}${suffix}` }
1968
- }
1969
- return { ok: false, reason: archivedList.ok && activeList.ok ? archived.error : `${archived.error}; archive state is unknown and could not be reconciled` }
2269
+ for (const id of plan.activeIds) {
2270
+ const archived = await codexThreadMutation(sock, 'thread/archive', id, fence)
2271
+ if (!archived.ok) return compensate(`${archived.error} while archiving Codex subtree member ${id}`)
1970
2272
  }
1971
2273
  let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
1972
2274
  const verifyDeadline = Date.now() + 30_000
@@ -1976,15 +2278,15 @@ export const codexHarness: Harness = {
1976
2278
  if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
1977
2279
  }
1978
2280
  if (verified.ok) return verified
1979
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
1980
- return { ok: false, reason: `${verified.reason}; shared Codex app-server generation changed, so no compensation was attempted` }
1981
- const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
1982
- const active = await codexThreadList(sock, { archived: false, sourceKinds: [] })
1983
- const suffix = restored.ok && active.ok && active.ids.includes(rec.harnessSessionId) ? '' : '; compensation failed or archive state is unknown'
1984
- return { ok: false, reason: `${verified.reason}${suffix}` }
2281
+ return compensate(verified.reason)
1985
2282
  },
1986
- restoreRuntime: async (rec) => {
2283
+ restoreRuntime: async (rec, suppliedReceipt) => {
1987
2284
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2285
+ if (suppliedReceipt !== undefined) {
2286
+ if (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== rec.harnessSessionId)
2287
+ return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
2288
+ return codexRestoreColdPlan(suppliedReceipt)
2289
+ }
1988
2290
  const sock = codexAppServerSock(runtimeRoot())
1989
2291
  const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
1990
2292
  const [active, archived] = await Promise.all([
@@ -2006,7 +2308,7 @@ export const codexHarness: Harness = {
2006
2308
  key: 'codex-app-server',
2007
2309
  label: 'Codex app-server',
2008
2310
  pidFile: codexAppServerPid(runtimeDir),
2009
- isolationFile: codexAppServerIsolation(runtimeDir),
2311
+ receiptFile: codexAppServerReceipt(runtimeDir),
2010
2312
  residency: async () => {
2011
2313
  const sock = codexAppServerSock(runtimeDir)
2012
2314
  let pid = 0
@@ -2021,7 +2323,7 @@ export const codexHarness: Harness = {
2021
2323
  const result = await codexLoadedReferenceIds(sock)
2022
2324
  return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
2023
2325
  },
2024
- mutationGuard: (targetReferenceId) => codexTargetMutationGuard(targetReferenceId, runtimeDir),
2326
+ mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts),
2025
2327
  probe: () => codexSharedRuntimeProbe(runtimeDir),
2026
2328
  }],
2027
2329
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
@@ -2167,6 +2469,7 @@ export const codexHeadlessHarness: Harness = {
2167
2469
  // one-run defence. See pi-harness.ts for the extension source + trust mechanics.
2168
2470
  export const piHarness: Harness = {
2169
2471
  id: 'pi',
2472
+ dispatchId: 'pi',
2170
2473
  headless: false,
2171
2474
  events: PI_EVENTS,
2172
2475
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
@@ -2177,6 +2480,7 @@ export const piHarness: Harness = {
2177
2480
  sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
2178
2481
  launchEnv: rendezvousLaunchEnv,
2179
2482
  shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
2483
+ shimScope: 'tree',
2180
2484
  worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
2181
2485
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
2182
2486
  skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
@@ -2187,7 +2491,7 @@ export const piHarness: Harness = {
2187
2491
  }),
2188
2492
  writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
2189
2493
  removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
2190
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2494
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2191
2495
  slashCommands: piSlashCommands,
2192
2496
  // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
2193
2497
  // socket the generated extension binds. socketLive is already probed for every windowed session.
@@ -2222,6 +2526,7 @@ export const piHeadlessHarness: Harness = {
2222
2526
 
2223
2527
  export const opencodeHarness: Harness = {
2224
2528
  id: 'opencode',
2529
+ dispatchId: 'opencode',
2225
2530
  headless: false,
2226
2531
  events: OPENCODE_EVENTS,
2227
2532
  // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
@@ -2241,6 +2546,7 @@ export const opencodeHarness: Harness = {
2241
2546
  // the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
2242
2547
  // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
2243
2548
  shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
2549
+ shimScope: 'tree',
2244
2550
  worktreeHookAnchor: () => null,
2245
2551
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
2246
2552
  skillDir: (proj) => join(proj, '.opencode', 'skills'),
@@ -2250,7 +2556,7 @@ export const opencodeHarness: Harness = {
2250
2556
  shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
2251
2557
  writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
2252
2558
  removeTrust: () => { /* nothing was written */ },
2253
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2559
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2254
2560
  slashCommands: opencodeSlashCommands,
2255
2561
  // online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
2256
2562
  // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to