spexcode 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
2
- import { join, dirname } from 'node:path'
2
+ import { join, dirname, basename } 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'
@@ -19,6 +19,7 @@ import { shQuote } from './sh.js'
19
19
  import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
20
20
  import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
21
21
  import { writeFileIfChanged } from './file-write.js'
22
+ import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace, type ExecutionTrace, type ExecutionTurn } from './execution-trace.js'
22
23
 
23
24
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
24
25
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -32,7 +33,7 @@ import { writeFileIfChanged } from './file-write.js'
32
33
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
33
34
  // (materialize writes every harness's artifacts).
34
35
 
35
- export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
36
+ export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
36
37
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null; stopped?: boolean; archived?: boolean }
37
38
  export type HarnessLaunchReadyRecord = HarnessLivenessRecord & { governed?: boolean; runtimeDir: string }
38
39
  export type HarnessLaunchReadinessFence = {
@@ -149,7 +150,7 @@ export async function adapterLoadedReferenceState(
149
150
  export interface Harness {
150
151
  readonly id: HarnessId
151
152
  // the id baked into the materialized shim. Headless variants reuse their native family's shim.
152
- readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
153
+ readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode'
153
154
  // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
154
155
  // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
155
156
  readonly headless: boolean
@@ -183,6 +184,9 @@ export interface Harness {
183
184
  // instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
184
185
  // is data on the adapter, not an `if (codex)` in sessions.ts.
185
186
  readonly paneTitleIsSelfSummary: boolean
187
+ // The adapter-only native transcript reader. Its compact result has no raw envelope, argument, output, or
188
+ // reasoning data; product surfaces receive only the latest working note and typed tool steps.
189
+ executionTrace(threadId: string, turn: ExecutionTurn | null): ExecutionTrace | null
186
190
  // --- launch / sessionId ---
187
191
  // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
188
192
  // visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
@@ -523,7 +527,62 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
523
527
 
524
528
  // The timeline is the message's copy, so rendezvous needs no receipt protocol. It writes one idempotent poke
525
529
  // carrying the timeline mid and reports only whether that write reached the local transport.
526
- function replyViaSocket(sock: string, text: string, mid?: string): Promise<DispatchResult> {
530
+ type ClaudeForkTransport = { sock: string; auth: string }
531
+
532
+ // The backend need not share the agent's config root: an explicitly chosen launcher can point Claude at its
533
+ // own home. A moved source process remains alive by definition, so its one config-dir environment field is the
534
+ // live authority for locating the daemon roster. A stale/reused pid can at worst name a roster with no exact
535
+ // successor; it cannot select one without the moved/session-source checks below.
536
+ function claudeConfigRoots(sourceSessionId: string, runtimeDir?: string): string[] {
537
+ const roots: string[] = []
538
+ try {
539
+ const pidFile = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'agent.pid') : sessionArtifactPath(sourceSessionId, 'agent.pid')
540
+ const pid = Number(readFileSync(pidFile, 'utf8').trim())
541
+ if (Number.isInteger(pid) && pid > 0) {
542
+ const env = readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')
543
+ const config = env.find((entry) => entry.startsWith('CLAUDE_CONFIG_DIR='))?.slice('CLAUDE_CONFIG_DIR='.length)
544
+ if (config) roots.push(config)
545
+ }
546
+ } catch { /* source not available or this platform does not expose procfs */ }
547
+ if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
548
+ roots.push(join(homedir(), '.claude'))
549
+ return [...new Set(roots)]
550
+ }
551
+
552
+ // A moved Claude conversation is a daemon-owned fork. Its launch-time socket still answers, but that process
553
+ // no longer returns to its prompt. A successor hook may have already recorded its exact Claude session id in
554
+ // `moved`; use that durable identity first, then retain the roster's source-transcript relation for deployments
555
+ // without the stamp. The roster remains the sole source of the live socket and current auth token. Keep this
556
+ // lookup Claude-local so other rendezvous adapters cannot adopt a coincidentally matching id.
557
+ function claudeForkTransport(sourceSessionId: string, runtimeDir?: string): ClaudeForkTransport | null {
558
+ const moved = (() => {
559
+ try {
560
+ const stamp = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'moved') : sessionArtifactPath(sourceSessionId, 'moved')
561
+ return readFileSync(stamp, 'utf8').trim()
562
+ } catch {
563
+ return ''
564
+ }
565
+ })()
566
+ for (const configDir of claudeConfigRoots(sourceSessionId, runtimeDir)) {
567
+ try {
568
+ const roster = JSON.parse(readFileSync(join(configDir, 'daemon', 'roster.json'), 'utf8')) as { workers?: Record<string, any> }
569
+ const workers = Object.values(roster.workers ?? {})
570
+ const usable = (worker: any) => typeof worker?.rendezvousSock === 'string' && typeof worker.rvAuth === 'string'
571
+ const recorded = workers.filter((worker) => usable(worker) && moved && worker?.sessionId === moved)
572
+ const candidates = recorded.length ? recorded : workers.filter((worker) => {
573
+ const launch = worker?.dispatch?.launch
574
+ if (launch?.mode !== 'resume' || launch.fork !== true || typeof launch.sessionId !== 'string') return false
575
+ const source = basename(launch.sessionId).replace(/\.jsonl$/, '')
576
+ return source === sourceSessionId && usable(worker)
577
+ })
578
+ const worker = candidates.sort((a, b) => Number(b.startedAt ?? 0) - Number(a.startedAt ?? 0))[0]
579
+ if (worker) return { sock: worker.rendezvousSock, auth: worker.rvAuth }
580
+ } catch { /* this Claude config has no readable daemon roster */ }
581
+ }
582
+ return null
583
+ }
584
+
585
+ function replyViaSocket(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
527
586
  return new Promise((resolve) => {
528
587
  let settled = false
529
588
  let c: ReturnType<typeof createConnection>
@@ -545,7 +604,7 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
545
604
  })
546
605
  c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }))
547
606
  c.on('connect', () => c.write(
548
- JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) }) + '\n',
607
+ `${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`,
549
608
  (error) => {
550
609
  if (error) return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` })
551
610
  c.end()
@@ -555,16 +614,32 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
555
614
  })
556
615
  }
557
616
  const POKE_ATTEMPTS = 2
558
- export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
559
- const sock = rvSock(id)
617
+ async function pokeRendezvous(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
560
618
  let last: DispatchResult = { ok: false, error: 'not attempted' }
561
619
  for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
562
- last = await replyViaSocket(sock, text, mid)
620
+ last = await replyViaSocket(sock, text, mid, auth)
563
621
  if (last.ok) return last
564
622
  }
565
623
  return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` }
566
624
  }
567
625
 
626
+ export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
627
+ return pokeRendezvous(rvSock(id), text, mid)
628
+ }
629
+
630
+ export async function deliverViaClaudeRendezvous(id: string, text: string, mid?: string, runtimeDir?: string): Promise<DispatchResult> {
631
+ const fork = claudeForkTransport(id, runtimeDir)
632
+ const sourceSock = rvSock(id)
633
+ if (!fork) return pokeRendezvous(sourceSock, text, mid)
634
+
635
+ const forkResult = await pokeRendezvous(fork.sock, text, mid, fork.auth)
636
+ if (forkResult.ok || fork.sock === sourceSock) return forkResult
637
+
638
+ const sourceResult = await pokeRendezvous(sourceSock, text, mid)
639
+ if (sourceResult.ok) return sourceResult
640
+ return { ok: false, error: `fork rendezvous failed: ${forkResult.error}; source fallback failed: ${sourceResult.error}` }
641
+ }
642
+
568
643
  export async function deliverViaSocketOrWake(
569
644
  id: string,
570
645
  text: string,
@@ -905,50 +980,61 @@ export function codexTurnFailureObserver(
905
980
  return { close: () => finish(null), closed }
906
981
  }
907
982
 
908
- // Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
909
- // defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
983
+ // Protocol-verified cold/restore/control seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
984
+ // defines thread/archive and thread/unarchive with {threadId}, plus turn/interrupt with {threadId, turnId}; no
985
+ // guessed method or process command is used.
910
986
  type CodexGenerationFence = { dir: string; endpoint: CodexGenerationEndpoint; generation: string }
911
- function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive', threadId: string, fence?: CodexGenerationFence): Promise<{ ok: true } | { ok: false; error: string }> {
987
+ // A failed mutation says whether the server can still commit it. `refused` means the request never reached the
988
+ // server or the server answered by rejecting it, so the target is provably unchanged and compensation is safe.
989
+ // `unknown` means the request was sent and no verdict came back — the server may still be executing it, so
990
+ // sending anything else down the same connection queues behind that work and fails too.
991
+ type CodexMutationOutcome = { ok: true } | { ok: false; error: string; commit: 'refused' | 'unknown' }
992
+ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive' | 'turn/interrupt', threadId: string, fence?: CodexGenerationFence, turnId?: string, budgetMs = CODEX_MUTATION_BASE_MS): Promise<CodexMutationOutcome> {
912
993
  const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
913
994
  ? `Codex ${method} refused because the shared app-server generation changed`
914
995
  : null
915
996
  const before = generationError()
916
- if (before) return Promise.resolve({ ok: false, error: before })
997
+ if (before) return Promise.resolve({ ok: false, error: before, commit: 'refused' })
998
+ if (method === 'turn/interrupt' && !turnId)
999
+ return Promise.resolve({ ok: false, error: 'Codex turn interrupt needs an exact turn id', commit: 'refused' })
917
1000
  return new Promise((resolve) => {
918
1001
  const conn: Socket = createConnection(sock)
919
1002
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
920
- let upgraded = false, settled = false
921
- const done = (r: { ok: true } | { ok: false; error: string }) => {
1003
+ let upgraded = false, settled = false, requested = false
1004
+ const done = (r: CodexMutationOutcome) => {
922
1005
  if (settled) return
923
1006
  settled = true
924
1007
  clearTimeout(timer)
925
1008
  try { conn.destroy() } catch {}
926
1009
  resolve(r)
927
1010
  }
928
- // thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
929
- // legitimate late response is not turned into an early timeout race.
930
- const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
931
- conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
932
- conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${method}` }) })
1011
+ // Once the request is on the wire the server owns it, so every unanswered end — timeout, socket error,
1012
+ // early close, a generation swap leaves the commit unknown rather than refused.
1013
+ const unanswered = (error: string) => done({ ok: false, error, commit: requested ? 'unknown' : 'refused' })
1014
+ const timer = setTimeout(() => unanswered(generationError() || `Codex ${method} did not answer within ${budgetMs}ms`), budgetMs)
1015
+ conn.on('error', (e) => unanswered(generationError() || `Codex ${method} connection failed: ${rpcError(e)}`))
1016
+ conn.on('close', () => { if (!settled) unanswered(`Codex app-server closed during ${method}`) })
933
1017
  const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
934
1018
  conn.on('connect', () => {
935
1019
  const changed = generationError()
936
- if (changed) return done({ ok: false, error: changed })
1020
+ if (changed) return unanswered(changed)
937
1021
  conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
938
1022
  })
939
1023
  const handle = (json: string) => {
940
1024
  let m: JsonRpc
941
1025
  try { m = JSON.parse(json) } catch { return }
942
- if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}` })
1026
+ // The server answered by rejecting, so the target is provably unchanged whether or not we had sent it.
1027
+ if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}`, commit: 'refused' })
943
1028
  if (m.id === 1 && m.result) {
944
1029
  const changed = generationError()
945
- if (changed) return done({ ok: false, error: changed })
1030
+ if (changed) return unanswered(changed)
946
1031
  send({ method: 'initialized', params: {} })
947
- return send({ id: 2, method, params: { threadId } })
1032
+ requested = true
1033
+ return send({ id: 2, method, params: method === 'turn/interrupt' ? { threadId, turnId } : { threadId } })
948
1034
  }
949
1035
  if (m.id === 2 && m.result) {
950
1036
  const changed = generationError()
951
- return changed ? done({ ok: false, error: changed }) : done({ ok: true })
1037
+ return changed ? unanswered(changed) : done({ ok: true })
952
1038
  }
953
1039
  }
954
1040
  conn.on('data', (chunk: Buffer) => {
@@ -957,17 +1043,32 @@ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/un
957
1043
  const i = fs.buf.indexOf('\r\n\r\n')
958
1044
  if (i < 0) return
959
1045
  const head = fs.buf.slice(0, i).toString('utf8')
960
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}` })
1046
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}`, commit: 'refused' })
961
1047
  upgraded = true
962
1048
  fs.buf = fs.buf.slice(i + 4)
963
1049
  send(wsInitialize)
964
1050
  }
965
- if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${method}` })
1051
+ if (drainWsFrames(fs, conn, handle)) unanswered(`Codex app-server closed during ${method}`)
966
1052
  })
967
1053
  })
968
1054
  }
969
1055
 
970
1056
  type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
1057
+ // Dashboard/resource probes keep their own short budget; this target-scoped census is only entered by a
1058
+ // lifecycle mutation that already holds the session transition lock and must tolerate a busy app-server.
1059
+ const CODEX_MUTATION_CENSUS_MS = 15_000
1060
+ // A mutation's response budget. `thread/unarchive` and `turn/interrupt` are state flips the server answers at
1061
+ // once — measured 36ms to unarchive the very same 279 MB thread that took 47.7s to archive — so they keep the
1062
+ // base. `thread/archive` on a LOADED thread differs in kind: the server flushes that thread's whole in-memory
1063
+ // rollout inside shutdown_and_wait before it commits, so the wait is proportional to accumulated history
1064
+ // (measured 47.7s for 279 MB, ~5.9 MB/s, against ~1.5s for a notLoaded member that flushes nothing). A fixed
1065
+ // ceiling therefore never bounds the operation; it only picks the transcript size above which archive stops
1066
+ // working, and raising it just moves that size. The scaled term is deliberately pessimistic — a floor rate ~6x
1067
+ // under the measured one — because its job is to catch a WEDGED server, not to predict a flush: a machine
1068
+ // several times slower still archives, while a hung one still fails loudly.
1069
+ const CODEX_MUTATION_BASE_MS = 15_000
1070
+ const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000
1071
+ const codexArchiveBudgetMs = (bytes: number) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS)
971
1072
  // Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
972
1073
  // entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
973
1074
  // supplies every protocol source kind explicitly for its thread/list calls.
@@ -982,6 +1083,7 @@ function codexPagedIds(
982
1083
  extractId: (item: unknown) => string | null,
983
1084
  label: string,
984
1085
  onItem?: (item: unknown) => void,
1086
+ timeoutMs = CODEX_MUTATION_CENSUS_MS,
985
1087
  ): Promise<CodexPagedIdsResult> {
986
1088
  return new Promise((resolve) => {
987
1089
  const conn: Socket = createConnection(sock)
@@ -992,7 +1094,7 @@ function codexPagedIds(
992
1094
  if (settled) return
993
1095
  settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
994
1096
  }
995
- const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out` }), 5000)
1097
+ const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out after ${timeoutMs}ms` }), timeoutMs)
996
1098
  conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
997
1099
  conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
998
1100
  const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
@@ -1041,18 +1143,23 @@ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true;
1041
1143
  return result.ok ? { ok: true, referenceIds: result.ids } : result
1042
1144
  }
1043
1145
 
1044
- const CODEX_TARGET_TURN_CENSUS_MS = 15_000
1146
+ const CODEX_RUNNING_TURN_READ_MS = 15_000
1045
1147
 
1046
- function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }> {
1148
+ // @@@ presence vs identity - two different questions, deliberately not one helper.
1149
+ // A gate asks "is a turn in flight right now"; thread/list answers that for every thread at once, at a cost
1150
+ // that tracks the thread COUNT. Interrupt must additionally name the turn to interrupt, and only a turn read
1151
+ // carries the id — a cost that tracks that one thread's persisted HISTORY. So this read stays for interrupt,
1152
+ // where the target is by definition active and short-lived, and no gate may be routed back through it.
1153
+ function codexRunningTurn(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }> {
1047
1154
  return new Promise((resolve) => {
1048
1155
  const conn: Socket = createConnection(sock)
1049
1156
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1050
1157
  let upgraded = false, settled = false
1051
- const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }) => {
1158
+ const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }) => {
1052
1159
  if (settled) return
1053
1160
  settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
1054
1161
  }
1055
- const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${CODEX_TARGET_TURN_CENSUS_MS}ms` }), CODEX_TARGET_TURN_CENSUS_MS)
1162
+ const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${CODEX_RUNNING_TURN_READ_MS}ms` }), CODEX_RUNNING_TURN_READ_MS)
1056
1163
  conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }))
1057
1164
  conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }) })
1058
1165
  const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
@@ -1070,7 +1177,12 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
1070
1177
  if (message.id !== 2 || !message.result) return
1071
1178
  const turns = (message.result as { data?: unknown }).data
1072
1179
  if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
1073
- return done({ ok: true, turnPresence: turns.some((turn) => turn && typeof turn === 'object' && (turn as { status?: unknown }).status === 'inProgress') ? 'active' : 'idle' })
1180
+ const active = turns.find((turn): turn is { id?: unknown; status?: unknown } =>
1181
+ !!turn && typeof turn === 'object' && (turn as { status?: unknown }).status === 'inProgress')
1182
+ if (!active) return done({ ok: true, turnPresence: 'idle' })
1183
+ return typeof active.id === 'string' && active.id
1184
+ ? done({ ok: true, turnPresence: 'active', turnId: active.id })
1185
+ : done({ ok: true, turnPresence: 'unknown' })
1074
1186
  }
1075
1187
  conn.on('data', (chunk: Buffer) => {
1076
1188
  fs.buf = Buffer.concat([fs.buf, chunk])
@@ -1086,14 +1198,57 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
1086
1198
  })
1087
1199
  }
1088
1200
 
1201
+ const CODEX_INTERRUPT_SETTLE_MS = 15_000
1202
+
1203
+ async function interruptCodexTurn(rec: HarnessDeliveryRecord): Promise<DispatchResult> {
1204
+ if (!rec.harnessSessionId) return { ok: false, error: 'no exact Codex thread identity is registered' }
1205
+ const threadId = rec.harnessSessionId
1206
+ const dir = rec.runtimeDir || runtimeRoot()
1207
+ const endpoint = codexEndpointForRecord(rec, dir)
1208
+ if (!endpoint) return { ok: false, error: 'no exact Codex generation binding is registered for this target' }
1209
+ const generation = codexRuntimeGeneration(dir, endpoint)
1210
+ if (!generation) return { ok: false, error: 'Codex shared app-server generation is unproven' }
1211
+ const fence = { dir, endpoint, generation }
1212
+ const before = await codexRunningTurn(endpoint.socketPath, threadId)
1213
+ if (!before.ok) return { ok: false, error: before.error }
1214
+ if (codexRuntimeGeneration(dir, endpoint) !== generation)
1215
+ return { ok: false, error: 'shared Codex app-server generation changed during interrupt preflight' }
1216
+ if (before.turnPresence === 'idle') return { ok: true }
1217
+ if (before.turnPresence !== 'active' || !before.turnId)
1218
+ return { ok: false, error: `Codex target thread ${threadId} turn state is unknown` }
1219
+ const interrupted = await codexThreadMutation(endpoint.socketPath, 'turn/interrupt', threadId, fence, before.turnId)
1220
+ if (!interrupted.ok) return { ok: false, error: interrupted.error }
1221
+ const deadline = Date.now() + CODEX_INTERRUPT_SETTLE_MS
1222
+ for (;;) {
1223
+ const after = await codexRunningTurn(endpoint.socketPath, threadId)
1224
+ if (!after.ok) return { ok: false, error: after.error }
1225
+ if (codexRuntimeGeneration(dir, endpoint) !== generation)
1226
+ return { ok: false, error: 'shared Codex app-server generation changed during interrupt settlement' }
1227
+ if (after.turnPresence === 'idle') return { ok: true }
1228
+ if (after.turnPresence === 'unknown') return { ok: false, error: `Codex target thread ${threadId} turn state is unknown after interrupt` }
1229
+ if (Date.now() >= deadline) return { ok: false, error: `Codex target thread ${threadId} remained active after interrupt` }
1230
+ await new Promise((resolve) => setTimeout(resolve, 100))
1231
+ }
1232
+ }
1233
+
1089
1234
  // The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
1090
1235
  // a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
1091
1236
  export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
1092
1237
  return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
1093
1238
  }
1094
1239
 
1240
+ // Every thread/list row carries the app-server's live turn state for that thread, in the protocol's own
1241
+ // three variants. `notLoaded` duplicates what thread/loaded/list reports; `idle`/`active` answer the only
1242
+ // question a lifecycle gate asks. Any other shape is `unknown` and fails closed — never derived from
1243
+ // something cheaper.
1244
+ type CodexThreadStatus = 'notLoaded' | 'idle' | 'active' | 'unknown'
1245
+ const codexRowStatus = (row: { status?: unknown }): CodexThreadStatus => {
1246
+ const type = (row.status as { type?: unknown } | null | undefined)?.type
1247
+ return type === 'notLoaded' || type === 'idle' || type === 'active' ? type : 'unknown'
1248
+ }
1249
+
1095
1250
  type CodexThreadCollectionResult =
1096
- | { ok: true; ids: string[]; parentById: Map<string, string | null> }
1251
+ | { ok: true; ids: string[]; parentById: Map<string, string | null>; statusById: Map<string, CodexThreadStatus> }
1097
1252
  | { ok: false; error: string }
1098
1253
 
1099
1254
  function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
@@ -1101,6 +1256,7 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
1101
1256
  ? params.sourceKinds
1102
1257
  : [...CODEX_THREAD_SOURCE_KINDS]
1103
1258
  const parentById = new Map<string, string | null>()
1259
+ const statusById = new Map<string, CodexThreadStatus>()
1104
1260
  const conflictingParents = new Set<string>()
1105
1261
  return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
1106
1262
  if (typeof item === 'string') return item
@@ -1108,26 +1264,40 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
1108
1264
  return typeof id === 'string' ? id : null
1109
1265
  }, 'thread/list', (item) => {
1110
1266
  if (!item || typeof item !== 'object') return
1111
- const row = item as { id?: unknown; parentThreadId?: unknown }
1267
+ const row = item as { id?: unknown; parentThreadId?: unknown; status?: unknown }
1112
1268
  if (typeof row.id !== 'string') return
1113
1269
  const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
1114
1270
  if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
1115
1271
  parentById.set(row.id, parent)
1272
+ // Parent ownership is a fact about the graph, so a disagreement across pages is a census fault.
1273
+ // Turn state is live, so a mid-drain change is not a fault — it is simply no longer knowable here.
1274
+ const status = codexRowStatus(row)
1275
+ statusById.set(row.id, statusById.has(row.id) && statusById.get(row.id) !== status ? 'unknown' : status)
1116
1276
  }).then((result) => {
1117
1277
  if (!result.ok) return result
1118
1278
  if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
1119
- return { ...result, parentById }
1279
+ return { ...result, parentById, statusById }
1120
1280
  })
1121
1281
  }
1122
1282
 
1283
+ // The gate's question is about the tip — is a turn in flight right now — and thread/list already answers it
1284
+ // for every thread at once, at a cost that tracks how many threads exist. Reading turns instead costs the
1285
+ // target's whole persisted history against a fixed budget, so a long-lived session becomes unmutatable.
1286
+ const codexPresenceFromStatus = (status: CodexThreadStatus | undefined): SharedRuntimeMutationGuard['targetTurnPresence'] =>
1287
+ status === 'idle' || status === 'active' ? status : 'unknown'
1288
+
1123
1289
  async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeMutationGuard> {
1124
1290
  const generationBefore = codexMutationGeneration(dir, endpoint)
1125
1291
  if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
1126
1292
  const sock = endpoint.socketPath
1127
- const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
1293
+ // The descendant collections are ancestor-filtered and therefore exclude the target itself, so the
1294
+ // target's own turn state comes from the whole-collection census. These run concurrently with the rest.
1295
+ const [loaded, activeDescendants, archivedDescendants, activeList, archivedList] = await Promise.all([
1128
1296
  codexLoadedReferenceIds(sock),
1129
1297
  codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1130
1298
  codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1299
+ codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
1300
+ codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
1131
1301
  ])
1132
1302
  const referenceIds = loaded.ok ? loaded.referenceIds : []
1133
1303
  const descendantIds = activeDescendants.ok && archivedDescendants.ok
@@ -1136,12 +1306,11 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), e
1136
1306
  if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
1137
1307
  if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
1138
1308
  if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
1139
- let targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = 'none'
1140
- if (referenceIds.includes(threadId)) {
1141
- const target = await codexTargetTurnPresence(sock, threadId)
1142
- if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
1143
- targetTurnPresence = target.turnPresence
1144
- }
1309
+ if (!activeList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeList.error }
1310
+ if (!archivedList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedList.error }
1311
+ const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = referenceIds.includes(threadId)
1312
+ ? codexPresenceFromStatus(activeList.statusById.get(threadId) ?? archivedList.statusById.get(threadId))
1313
+ : 'none'
1145
1314
  if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
1146
1315
  return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
1147
1316
  return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
@@ -1184,7 +1353,7 @@ function isEndpointLike(value: unknown): value is CodexGenerationEndpoint {
1184
1353
  typeof (value as CodexGenerationEndpoint).socketPath === 'string'
1185
1354
  }
1186
1355
 
1187
- async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
1356
+ async function codexColdPreflightOnce(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
1188
1357
  const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint)
1189
1358
  if (!generation || codexRuntimeGeneration(dir, endpoint) !== generation)
1190
1359
  return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
@@ -1193,8 +1362,8 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1193
1362
  codexLoadedReferenceIds(sock),
1194
1363
  codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1195
1364
  codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1196
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1197
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1365
+ codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
1366
+ codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
1198
1367
  ])
1199
1368
  if (codexRuntimeGeneration(dir, endpoint) !== generation)
1200
1369
  return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
@@ -1246,18 +1415,20 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1246
1415
  }
1247
1416
  }
1248
1417
 
1418
+ // Every subtree member was just proven to occur in exactly one whole-collection census, so that census
1419
+ // already carries each one's live turn state. No second round of native reads, and therefore no second
1420
+ // generation fence — nothing was read between the fence above and here.
1421
+ const statusById = new Map([...activeList.statusById, ...archivedList.statusById])
1249
1422
  const loadedSet = new Set(loaded.referenceIds)
1250
1423
  const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
1251
- const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
1252
- if (codexRuntimeGeneration(dir, endpoint) !== generation)
1253
- return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
1254
- for (const { id, state } of turnStates) {
1255
- if (!state.ok) return { ok: false, reason: state.error }
1256
- if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
1257
- if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
1424
+ for (const id of loadedSubtreeIds) {
1425
+ const presence = codexPresenceFromStatus(statusById.get(id))
1426
+ if (presence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
1427
+ if (presence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
1258
1428
  if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
1259
1429
  }
1260
1430
 
1431
+ // Proven, not assumed: a loaded target is one of the members the loop above just cleared.
1261
1432
  const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
1262
1433
  const guard: SharedRuntimeMutationGuard = {
1263
1434
  healthy: true,
@@ -1286,6 +1457,28 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1286
1457
  return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
1287
1458
  }
1288
1459
 
1460
+ // A busy app-server can refuse one WebSocket census while accepting the next. The refusal is transport-local,
1461
+ // so retry the complete proof (including generation fencing) within the terminal operation's finite budget;
1462
+ // semantic ownership refusals still return immediately and never turn into repeated native reads.
1463
+ const CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS = 6
1464
+ const CODEX_COLD_PREFLIGHT_RETRY_MS = 250
1465
+ const CODEX_COLD_PREFLIGHT_DEADLINE_MS = 30_000
1466
+ const isTransientCodexCensusFailure = (reason: string): boolean =>
1467
+ /(?:timed out|connection|closed during|refused .*census|census failed|app-server busy)/i.test(reason)
1468
+
1469
+ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
1470
+ const deadline = Date.now() + CODEX_COLD_PREFLIGHT_DEADLINE_MS
1471
+ for (let attempt = 0; attempt < CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS; attempt++) {
1472
+ const result = await codexColdPreflightOnce(threadId, dir, expectedGeneration, endpoint)
1473
+ if (result.ok || !isTransientCodexCensusFailure(result.reason) || attempt === CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS - 1) return result
1474
+ const remaining = deadline - Date.now()
1475
+ if (remaining <= 0) return result
1476
+ const delay = Math.min(CODEX_COLD_PREFLIGHT_RETRY_MS * 2 ** attempt, remaining)
1477
+ await new Promise((resolve) => setTimeout(resolve, delay))
1478
+ }
1479
+ throw new Error('unreachable Codex cold preflight retry state')
1480
+ }
1481
+
1289
1482
  async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine> {
1290
1483
  const dir = runtimeRoot()
1291
1484
  const generation = codexMutationGeneration(dir)
@@ -1315,8 +1508,16 @@ async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSe
1315
1508
  return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
1316
1509
  const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
1317
1510
  const legacy = legacyCodexGenerationEndpoint(dir)
1318
- const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation })
1319
- if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}` }
1511
+ // Quarantine archives one exact orphan, so it pays the same flush a subtree member does when that orphan is
1512
+ // loaded; the budget is derived the same way rather than being a second, differently-wrong constant.
1513
+ let orphanBudgetMs = CODEX_MUTATION_BASE_MS
1514
+ if (plan.guard.referenceIds.includes(threadId)) {
1515
+ const rollout = codexRolloutBytes(threadId)
1516
+ if ('unreadable' in rollout) return { ok: false, reason: `Codex native thread ${threadId} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown` }
1517
+ orphanBudgetMs = codexArchiveBudgetMs(rollout.bytes)
1518
+ }
1519
+ const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation }, undefined, orphanBudgetMs)
1520
+ if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}${archived.commit === 'unknown' ? '; commit state is unknown' : ''}` }
1320
1521
  const after = await codexColdPreflight(threadId, dir, generation)
1321
1522
  const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
1322
1523
  if (!after.ok) {
@@ -1745,19 +1946,25 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
1745
1946
  // thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
1746
1947
  // rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
1747
1948
  // found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
1748
- const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
1749
1949
  // does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
1750
1950
  // (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
1751
1951
  // the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
1752
1952
  // dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
1753
1953
  // every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
1754
1954
  // "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
1755
- export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
1756
- const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
1757
- for (const y of kids(root)) for (const m of kids(join(root, y))) for (const d of kids(join(root, y, m))) {
1758
- if (kids(join(root, y, m, d)).some((f) => f.includes(threadId))) return true
1759
- }
1760
- return false
1955
+ export function codexRolloutExists(threadId: string, root?: string): boolean {
1956
+ return codexRolloutPath(threadId, root) !== null
1957
+ }
1958
+ // The same day-dir walk, answering how big that rollout is. `thread/archive` on a LOADED thread flushes the
1959
+ // thread's in-memory rollout inside the server's shutdown_and_wait before it commits, so this size IS the work
1960
+ // an archive asks for; a notLoaded thread flushes nothing and its size is irrelevant. NO rollout file is a real
1961
+ // `0`, not an error: a thread that has started but not yet persisted (thread/start alone writes none, and a
1962
+ // fresh app-server lags 2-4s) has nothing to flush, so refusing it would be a false refusal. Only a file that
1963
+ // exists and cannot be measured is unreadable, and that fails closed rather than passing as small.
1964
+ export function codexRolloutBytes(threadId: string, root?: string): { bytes: number } | { unreadable: true } {
1965
+ const path = codexRolloutPath(threadId, root)
1966
+ if (path) { try { return { bytes: statSync(path).size } } catch { return { unreadable: true } } }
1967
+ return { bytes: 0 }
1761
1968
  }
1762
1969
  // poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
1763
1970
  // caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
@@ -2027,6 +2234,9 @@ const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToo
2027
2234
  // agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
2028
2235
  // real gap as codex.
2029
2236
  const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
2237
+ // z-code reads Claude-compatible hooks but has neither the idle Notification nor StopFailure lifecycle event.
2238
+ // This is a real harness difference, not a TODO: the existing Claude-only idle state is unavailable.
2239
+ const ZCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
2030
2240
 
2031
2241
  // the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
2032
2242
  // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
@@ -2037,6 +2247,7 @@ const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
2037
2247
  const codexBaseCmd = (cmd?: string) => cmd || 'codex'
2038
2248
  const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
2039
2249
  const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
2250
+ const zcodeBaseCmd = (cmd?: string) => cmd || 'zcode'
2040
2251
 
2041
2252
  // @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
2042
2253
  // opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
@@ -2072,6 +2283,9 @@ const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDi
2072
2283
  const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
2073
2284
  (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
2074
2285
 
2286
+ const panePidLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane) =>
2287
+ (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline')
2288
+
2075
2289
  const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
2076
2290
 
2077
2291
  // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
@@ -2121,6 +2335,7 @@ export const claudeHarness: Harness = {
2121
2335
  events: CLAUDE_EVENTS,
2122
2336
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
2123
2337
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
2338
+ executionTrace: readProjectJsonlExecutionTrace,
2124
2339
  launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
2125
2340
  baseCmd: claudeBaseCmd,
2126
2341
  sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
@@ -2142,7 +2357,7 @@ export const claudeHarness: Harness = {
2142
2357
  // dead-pane-reads-working bug). See rendezvousListening.
2143
2358
  liveness: socketListenerLiveness,
2144
2359
  leafOwnerNeedle: (rec) => rec.session,
2145
- deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2360
+ deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
2146
2361
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2147
2362
  coldRuntime: async () => ({ ok: true }),
2148
2363
  // The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
@@ -2221,6 +2436,7 @@ export const codexHarness: Harness = {
2221
2436
  events: CODEX_EVENTS,
2222
2437
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
2223
2438
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
2439
+ executionTrace: readCodexExecutionTrace,
2224
2440
  launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
2225
2441
  baseCmd: codexBaseCmd,
2226
2442
  sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
@@ -2284,6 +2500,7 @@ export const codexHarness: Harness = {
2284
2500
  leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
2285
2501
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
2286
2502
  observeTurnFailures: codexTurnFailureObserver,
2503
+ interrupt: interruptCodexTurn,
2287
2504
  cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
2288
2505
  targetDescriptorKey: (rec) => {
2289
2506
  const endpoint = codexEndpointForRecord(rec)
@@ -2359,9 +2576,24 @@ export const codexHarness: Harness = {
2359
2576
  if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
2360
2577
  return { ok: true }
2361
2578
  }
2579
+ const loadedSet = new Set(plan.guard.referenceIds)
2362
2580
  for (const id of plan.activeIds) {
2363
- const archived = await codexThreadMutation(sock, 'thread/archive', id, fence)
2364
- if (!archived.ok) return compensate(`${archived.error} while archiving Codex subtree member ${id}`)
2581
+ // Only a loaded member pays the rollout flush, and an unreadable size must not become a small budget,
2582
+ // so it fails closed before the server is asked to mutate anything.
2583
+ let budgetMs = CODEX_MUTATION_BASE_MS
2584
+ if (loadedSet.has(id)) {
2585
+ const rollout = codexRolloutBytes(id)
2586
+ if ('unreadable' in rollout) return compensate(`Codex subtree member ${id} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown`)
2587
+ budgetMs = codexArchiveBudgetMs(rollout.bytes)
2588
+ }
2589
+ const archived = await codexThreadMutation(sock, 'thread/archive', id, fence, undefined, budgetMs)
2590
+ if (archived.ok) continue
2591
+ const reason = `${archived.error} while archiving Codex subtree member ${id}`
2592
+ // Compensating an unknown commit is what turns one slow member into a false "compensation failed": the
2593
+ // unarchive queues behind an archive the server is still executing and times out too. Report the unknown
2594
+ // commit instead — that is the recovery token resume already reconciles.
2595
+ if (archived.commit === 'unknown') return { ok: false, reason: `${reason}; commit state is unknown and no compensation was attempted` }
2596
+ return compensate(reason)
2365
2597
  }
2366
2598
  let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
2367
2599
  const verifyDeadline = Date.now() + 30_000
@@ -2552,6 +2784,7 @@ export const piHarness: Harness = {
2552
2784
  events: PI_EVENTS,
2553
2785
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
2554
2786
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
2787
+ executionTrace: readSessionJsonlExecutionTrace,
2555
2788
  launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
2556
2789
  baseCmd: piBaseCmd,
2557
2790
  sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
@@ -2602,6 +2835,44 @@ export const piHeadlessHarness: Harness = {
2602
2835
  resumeArg: (rec) => `--session ${rec.session}`,
2603
2836
  }
2604
2837
 
2838
+ const ZCODE_CONTROL_UNAVAILABLE = 'zcode has no control channel; start a new session instead of delivering to an existing one'
2839
+
2840
+ // z-code's app-server is stdin/stdout NDJSON, unlike Codex's Unix-socket WebSocket + thread RPC. This row
2841
+ // intentionally covers the one-turn `--prompt` launcher and Claude-compatible hooks only; control operations
2842
+ // refuse rather than pretending the incompatible protocol accepted them.
2843
+ export const zcodeHarness: Harness = {
2844
+ id: 'zcode',
2845
+ dispatchId: 'zcode',
2846
+ headless: true,
2847
+ launchOneShot: true,
2848
+ events: ZCODE_EVENTS,
2849
+ ownsRendezvous: false,
2850
+ paneTitleIsSelfSummary: false,
2851
+ executionTrace: noExecutionTrace,
2852
+ launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
2853
+ baseCmd: zcodeBaseCmd,
2854
+ sessionIdArg: () => '',
2855
+ sessionEnvVar: 'ZCODE_SESSION_ID',
2856
+ launchEnv: noLaunchEnv,
2857
+ shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
2858
+ shimScope: 'tree',
2859
+ worktreeHookAnchor: () => null,
2860
+ contractFiles: (proj) => [join(proj, 'AGENTS.md')],
2861
+ skillDir: (proj) => join(proj, '.zcode', 'skills'),
2862
+ agentDir: (proj) => join(proj, '.zcode', 'agents'),
2863
+ shim: (dispatch, spex) => buildShim('zcode', ZCODE_EVENTS, dispatch, spex),
2864
+ writeTrust: () => [],
2865
+ removeTrust: () => { /* z-code wrote no trust artifact */ },
2866
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2867
+ slashCommands: () => [],
2868
+ liveness: panePidLiveness,
2869
+ leafOwnerNeedle: (rec) => rec.session,
2870
+ deliver: async () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
2871
+ cleanupRuntime: async () => { /* one-shot z-code owns no SpexCode transport to remove */ },
2872
+ coldRuntime: async () => ({ ok: true }),
2873
+ resumeArg: () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
2874
+ }
2875
+
2605
2876
  export const opencodeHarness: Harness = {
2606
2877
  id: 'opencode',
2607
2878
  dispatchId: 'opencode',
@@ -2611,6 +2882,7 @@ export const opencodeHarness: Harness = {
2611
2882
  // socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
2612
2883
  ownsRendezvous: true,
2613
2884
  paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
2885
+ executionTrace: readLocalStoreExecutionTrace,
2614
2886
  launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
2615
2887
  baseCmd: opencodeBaseCmd,
2616
2888
  sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
@@ -2674,14 +2946,20 @@ export const opencodeHeadlessHarness: Harness = {
2674
2946
  }
2675
2947
 
2676
2948
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
2677
- export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
2949
+ export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
2678
2950
 
2679
2951
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
2680
2952
  export const defaultHarness: Harness = claudeHarness
2681
2953
 
2954
+ // the registry lookup as DATA. A sweep over records nobody is currently asking about (a removed plugin, a
2955
+ // renamed id) must report an unresolvable harness rather than abort, so it resolves through this.
2956
+ export function harnessByIdOrNull(id: string): Harness | null {
2957
+ return HARNESSES.find((x) => x.id === id) ?? null
2958
+ }
2959
+
2682
2960
  // resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
2683
2961
  export function harnessById(id: string): Harness {
2684
- const h = HARNESSES.find((x) => x.id === id)
2962
+ const h = harnessByIdOrNull(id)
2685
2963
  if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
2686
2964
  return h
2687
2965
  }