spexcode 0.6.0 → 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 (88) 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 +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -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'
@@ -18,6 +18,8 @@ import { git } from './git.js'
18
18
  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
+ import { writeFileIfChanged } from './file-write.js'
22
+ import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace, type ExecutionTrace, type ExecutionTurn } from './execution-trace.js'
21
23
 
22
24
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
23
25
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -31,7 +33,7 @@ import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGenera
31
33
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
32
34
  // (materialize writes every harness's artifacts).
33
35
 
34
- 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'
35
37
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null; stopped?: boolean; archived?: boolean }
36
38
  export type HarnessLaunchReadyRecord = HarnessLivenessRecord & { governed?: boolean; runtimeDir: string }
37
39
  export type HarnessLaunchReadinessFence = {
@@ -148,7 +150,7 @@ export async function adapterLoadedReferenceState(
148
150
  export interface Harness {
149
151
  readonly id: HarnessId
150
152
  // the id baked into the materialized shim. Headless variants reuse their native family's shim.
151
- readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
153
+ readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode'
152
154
  // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
153
155
  // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
154
156
  readonly headless: boolean
@@ -182,6 +184,9 @@ export interface Harness {
182
184
  // instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
183
185
  // is data on the adapter, not an `if (codex)` in sessions.ts.
184
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
185
190
  // --- launch / sessionId ---
186
191
  // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
187
192
  // visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
@@ -522,7 +527,62 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
522
527
 
523
528
  // The timeline is the message's copy, so rendezvous needs no receipt protocol. It writes one idempotent poke
524
529
  // carrying the timeline mid and reports only whether that write reached the local transport.
525
- 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> {
526
586
  return new Promise((resolve) => {
527
587
  let settled = false
528
588
  let c: ReturnType<typeof createConnection>
@@ -544,7 +604,7 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
544
604
  })
545
605
  c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }))
546
606
  c.on('connect', () => c.write(
547
- JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) }) + '\n',
607
+ `${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`,
548
608
  (error) => {
549
609
  if (error) return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` })
550
610
  c.end()
@@ -554,16 +614,32 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
554
614
  })
555
615
  }
556
616
  const POKE_ATTEMPTS = 2
557
- export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
558
- const sock = rvSock(id)
617
+ async function pokeRendezvous(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
559
618
  let last: DispatchResult = { ok: false, error: 'not attempted' }
560
619
  for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
561
- last = await replyViaSocket(sock, text, mid)
620
+ last = await replyViaSocket(sock, text, mid, auth)
562
621
  if (last.ok) return last
563
622
  }
564
623
  return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` }
565
624
  }
566
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
+
567
643
  export async function deliverViaSocketOrWake(
568
644
  id: string,
569
645
  text: string,
@@ -702,10 +778,12 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
702
778
  // thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
703
779
  `export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
704
780
  // The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
705
- // resume resolves its existing session/thread binding and therefore cannot jump an old conversation to a
706
- // replacement root. It prints only shell assignments for the exact proven endpoint.
781
+ // resume resolves its existing session/thread binding, so a LIVE root never has its conversation moved to a
782
+ // replacement. Both spellings carry the server command because either may be the launch that has to start a
783
+ // root: after a host restart the bound generation is a corpse, and resume rebuilds one to load the same
784
+ // on-disk rollout. It prints only shell assignments for the exact proven endpoint.
707
785
  'if [ "$1" = "--resume" ]; then',
708
- ` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" )" || exit 1`,
786
+ ` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" ${shQuote(server)} )" || exit 1`,
709
787
  'else',
710
788
  ` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
711
789
  'fi',
@@ -902,50 +980,61 @@ export function codexTurnFailureObserver(
902
980
  return { close: () => finish(null), closed }
903
981
  }
904
982
 
905
- // Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
906
- // 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.
907
986
  type CodexGenerationFence = { dir: string; endpoint: CodexGenerationEndpoint; generation: string }
908
- 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> {
909
993
  const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
910
994
  ? `Codex ${method} refused because the shared app-server generation changed`
911
995
  : null
912
996
  const before = generationError()
913
- 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' })
914
1000
  return new Promise((resolve) => {
915
1001
  const conn: Socket = createConnection(sock)
916
1002
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
917
- let upgraded = false, settled = false
918
- const done = (r: { ok: true } | { ok: false; error: string }) => {
1003
+ let upgraded = false, settled = false, requested = false
1004
+ const done = (r: CodexMutationOutcome) => {
919
1005
  if (settled) return
920
1006
  settled = true
921
1007
  clearTimeout(timer)
922
1008
  try { conn.destroy() } catch {}
923
1009
  resolve(r)
924
1010
  }
925
- // thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
926
- // legitimate late response is not turned into an early timeout race.
927
- const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
928
- conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
929
- 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}`) })
930
1017
  const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
931
1018
  conn.on('connect', () => {
932
1019
  const changed = generationError()
933
- if (changed) return done({ ok: false, error: changed })
1020
+ if (changed) return unanswered(changed)
934
1021
  conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
935
1022
  })
936
1023
  const handle = (json: string) => {
937
1024
  let m: JsonRpc
938
1025
  try { m = JSON.parse(json) } catch { return }
939
- 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' })
940
1028
  if (m.id === 1 && m.result) {
941
1029
  const changed = generationError()
942
- if (changed) return done({ ok: false, error: changed })
1030
+ if (changed) return unanswered(changed)
943
1031
  send({ method: 'initialized', params: {} })
944
- return send({ id: 2, method, params: { threadId } })
1032
+ requested = true
1033
+ return send({ id: 2, method, params: method === 'turn/interrupt' ? { threadId, turnId } : { threadId } })
945
1034
  }
946
1035
  if (m.id === 2 && m.result) {
947
1036
  const changed = generationError()
948
- return changed ? done({ ok: false, error: changed }) : done({ ok: true })
1037
+ return changed ? unanswered(changed) : done({ ok: true })
949
1038
  }
950
1039
  }
951
1040
  conn.on('data', (chunk: Buffer) => {
@@ -954,17 +1043,32 @@ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/un
954
1043
  const i = fs.buf.indexOf('\r\n\r\n')
955
1044
  if (i < 0) return
956
1045
  const head = fs.buf.slice(0, i).toString('utf8')
957
- 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' })
958
1047
  upgraded = true
959
1048
  fs.buf = fs.buf.slice(i + 4)
960
1049
  send(wsInitialize)
961
1050
  }
962
- 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}`)
963
1052
  })
964
1053
  })
965
1054
  }
966
1055
 
967
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)
968
1072
  // Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
969
1073
  // entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
970
1074
  // supplies every protocol source kind explicitly for its thread/list calls.
@@ -979,6 +1083,7 @@ function codexPagedIds(
979
1083
  extractId: (item: unknown) => string | null,
980
1084
  label: string,
981
1085
  onItem?: (item: unknown) => void,
1086
+ timeoutMs = CODEX_MUTATION_CENSUS_MS,
982
1087
  ): Promise<CodexPagedIdsResult> {
983
1088
  return new Promise((resolve) => {
984
1089
  const conn: Socket = createConnection(sock)
@@ -989,7 +1094,7 @@ function codexPagedIds(
989
1094
  if (settled) return
990
1095
  settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
991
1096
  }
992
- 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)
993
1098
  conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
994
1099
  conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
995
1100
  const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
@@ -1038,18 +1143,23 @@ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true;
1038
1143
  return result.ok ? { ok: true, referenceIds: result.ids } : result
1039
1144
  }
1040
1145
 
1041
- const CODEX_TARGET_TURN_CENSUS_MS = 15_000
1146
+ const CODEX_RUNNING_TURN_READ_MS = 15_000
1042
1147
 
1043
- 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 }> {
1044
1154
  return new Promise((resolve) => {
1045
1155
  const conn: Socket = createConnection(sock)
1046
1156
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
1047
1157
  let upgraded = false, settled = false
1048
- 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 }) => {
1049
1159
  if (settled) return
1050
1160
  settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
1051
1161
  }
1052
- 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)
1053
1163
  conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }))
1054
1164
  conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }) })
1055
1165
  const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
@@ -1067,7 +1177,12 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
1067
1177
  if (message.id !== 2 || !message.result) return
1068
1178
  const turns = (message.result as { data?: unknown }).data
1069
1179
  if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
1070
- 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' })
1071
1186
  }
1072
1187
  conn.on('data', (chunk: Buffer) => {
1073
1188
  fs.buf = Buffer.concat([fs.buf, chunk])
@@ -1083,14 +1198,57 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
1083
1198
  })
1084
1199
  }
1085
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
+
1086
1234
  // The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
1087
1235
  // a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
1088
1236
  export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
1089
1237
  return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
1090
1238
  }
1091
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
+
1092
1250
  type CodexThreadCollectionResult =
1093
- | { ok: true; ids: string[]; parentById: Map<string, string | null> }
1251
+ | { ok: true; ids: string[]; parentById: Map<string, string | null>; statusById: Map<string, CodexThreadStatus> }
1094
1252
  | { ok: false; error: string }
1095
1253
 
1096
1254
  function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
@@ -1098,6 +1256,7 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
1098
1256
  ? params.sourceKinds
1099
1257
  : [...CODEX_THREAD_SOURCE_KINDS]
1100
1258
  const parentById = new Map<string, string | null>()
1259
+ const statusById = new Map<string, CodexThreadStatus>()
1101
1260
  const conflictingParents = new Set<string>()
1102
1261
  return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
1103
1262
  if (typeof item === 'string') return item
@@ -1105,26 +1264,40 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
1105
1264
  return typeof id === 'string' ? id : null
1106
1265
  }, 'thread/list', (item) => {
1107
1266
  if (!item || typeof item !== 'object') return
1108
- const row = item as { id?: unknown; parentThreadId?: unknown }
1267
+ const row = item as { id?: unknown; parentThreadId?: unknown; status?: unknown }
1109
1268
  if (typeof row.id !== 'string') return
1110
1269
  const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
1111
1270
  if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
1112
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)
1113
1276
  }).then((result) => {
1114
1277
  if (!result.ok) return result
1115
1278
  if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
1116
- return { ...result, parentById }
1279
+ return { ...result, parentById, statusById }
1117
1280
  })
1118
1281
  }
1119
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
+
1120
1289
  async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeMutationGuard> {
1121
1290
  const generationBefore = codexMutationGeneration(dir, endpoint)
1122
1291
  if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
1123
1292
  const sock = endpoint.socketPath
1124
- 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([
1125
1296
  codexLoadedReferenceIds(sock),
1126
1297
  codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1127
1298
  codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1299
+ codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
1300
+ codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
1128
1301
  ])
1129
1302
  const referenceIds = loaded.ok ? loaded.referenceIds : []
1130
1303
  const descendantIds = activeDescendants.ok && archivedDescendants.ok
@@ -1133,12 +1306,11 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), e
1133
1306
  if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
1134
1307
  if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
1135
1308
  if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
1136
- let targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = 'none'
1137
- if (referenceIds.includes(threadId)) {
1138
- const target = await codexTargetTurnPresence(sock, threadId)
1139
- if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
1140
- targetTurnPresence = target.turnPresence
1141
- }
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'
1142
1314
  if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
1143
1315
  return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
1144
1316
  return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
@@ -1181,7 +1353,7 @@ function isEndpointLike(value: unknown): value is CodexGenerationEndpoint {
1181
1353
  typeof (value as CodexGenerationEndpoint).socketPath === 'string'
1182
1354
  }
1183
1355
 
1184
- 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> {
1185
1357
  const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint)
1186
1358
  if (!generation || codexRuntimeGeneration(dir, endpoint) !== generation)
1187
1359
  return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
@@ -1190,8 +1362,8 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1190
1362
  codexLoadedReferenceIds(sock),
1191
1363
  codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1192
1364
  codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1193
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1194
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1365
+ codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
1366
+ codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
1195
1367
  ])
1196
1368
  if (codexRuntimeGeneration(dir, endpoint) !== generation)
1197
1369
  return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
@@ -1243,18 +1415,20 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1243
1415
  }
1244
1416
  }
1245
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])
1246
1422
  const loadedSet = new Set(loaded.referenceIds)
1247
1423
  const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
1248
- const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
1249
- if (codexRuntimeGeneration(dir, endpoint) !== generation)
1250
- return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
1251
- for (const { id, state } of turnStates) {
1252
- if (!state.ok) return { ok: false, reason: state.error }
1253
- if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
1254
- 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` }
1255
1428
  if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
1256
1429
  }
1257
1430
 
1431
+ // Proven, not assumed: a loaded target is one of the members the loop above just cleared.
1258
1432
  const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
1259
1433
  const guard: SharedRuntimeMutationGuard = {
1260
1434
  healthy: true,
@@ -1283,6 +1457,28 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1283
1457
  return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
1284
1458
  }
1285
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
+
1286
1482
  async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine> {
1287
1483
  const dir = runtimeRoot()
1288
1484
  const generation = codexMutationGeneration(dir)
@@ -1312,8 +1508,16 @@ async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSe
1312
1508
  return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
1313
1509
  const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
1314
1510
  const legacy = legacyCodexGenerationEndpoint(dir)
1315
- const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation })
1316
- 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' : ''}` }
1317
1521
  const after = await codexColdPreflight(threadId, dir, generation)
1318
1522
  const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
1319
1523
  if (!after.ok) {
@@ -1742,19 +1946,25 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
1742
1946
  // thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
1743
1947
  // rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
1744
1948
  // found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
1745
- const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
1746
1949
  // does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
1747
1950
  // (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
1748
1951
  // the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
1749
1952
  // dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
1750
1953
  // every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
1751
1954
  // "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
1752
- export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
1753
- const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
1754
- for (const y of kids(root)) for (const m of kids(join(root, y))) for (const d of kids(join(root, y, m))) {
1755
- if (kids(join(root, y, m, d)).some((f) => f.includes(threadId))) return true
1756
- }
1757
- 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 }
1758
1968
  }
1759
1969
  // poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
1760
1970
  // caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
@@ -1797,17 +2007,16 @@ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string
1797
2007
  // idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
1798
2008
  // comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
1799
2009
  // (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
1800
- export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): void {
2010
+ export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): boolean {
1801
2011
  const [open, close] = comment
1802
2012
  const START = `${open}spexcode:start${close}`
1803
2013
  const END = `${open}spexcode:end${close}`
1804
2014
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1805
2015
  const block = `${START}\n${body}\n${END}`
1806
- let cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
2016
+ const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
1807
2017
  const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`)
1808
- if (re.test(cur)) cur = cur.replace(re, block)
1809
- else cur = cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
1810
- writeFileSync(file, cur)
2018
+ const next = re.test(cur) ? cur.replace(re, block) : cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
2019
+ return writeFileIfChanged(file, next)
1811
2020
  }
1812
2021
 
1813
2022
  // the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
@@ -1916,7 +2125,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
1916
2125
  const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
1917
2126
  const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
1918
2127
  if (!existsSync(home)) mkdirSync(home, { recursive: true })
1919
- writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
2128
+ writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
1920
2129
  return file
1921
2130
  }
1922
2131
 
@@ -2025,6 +2234,9 @@ const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToo
2025
2234
  // agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
2026
2235
  // real gap as codex.
2027
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
2028
2240
 
2029
2241
  // the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
2030
2242
  // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
@@ -2035,6 +2247,7 @@ const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
2035
2247
  const codexBaseCmd = (cmd?: string) => cmd || 'codex'
2036
2248
  const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
2037
2249
  const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
2250
+ const zcodeBaseCmd = (cmd?: string) => cmd || 'zcode'
2038
2251
 
2039
2252
  // @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
2040
2253
  // opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
@@ -2070,6 +2283,9 @@ const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDi
2070
2283
  const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
2071
2284
  (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
2072
2285
 
2286
+ const panePidLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane) =>
2287
+ (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline')
2288
+
2073
2289
  const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
2074
2290
 
2075
2291
  // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
@@ -2119,6 +2335,7 @@ export const claudeHarness: Harness = {
2119
2335
  events: CLAUDE_EVENTS,
2120
2336
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
2121
2337
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
2338
+ executionTrace: readProjectJsonlExecutionTrace,
2122
2339
  launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
2123
2340
  baseCmd: claudeBaseCmd,
2124
2341
  sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
@@ -2140,7 +2357,7 @@ export const claudeHarness: Harness = {
2140
2357
  // dead-pane-reads-working bug). See rendezvousListening.
2141
2358
  liveness: socketListenerLiveness,
2142
2359
  leafOwnerNeedle: (rec) => rec.session,
2143
- deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2360
+ deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
2144
2361
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2145
2362
  coldRuntime: async () => ({ ok: true }),
2146
2363
  // The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
@@ -2219,6 +2436,7 @@ export const codexHarness: Harness = {
2219
2436
  events: CODEX_EVENTS,
2220
2437
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
2221
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,
2222
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
2223
2441
  baseCmd: codexBaseCmd,
2224
2442
  sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
@@ -2282,6 +2500,7 @@ export const codexHarness: Harness = {
2282
2500
  leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
2283
2501
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
2284
2502
  observeTurnFailures: codexTurnFailureObserver,
2503
+ interrupt: interruptCodexTurn,
2285
2504
  cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
2286
2505
  targetDescriptorKey: (rec) => {
2287
2506
  const endpoint = codexEndpointForRecord(rec)
@@ -2357,9 +2576,24 @@ export const codexHarness: Harness = {
2357
2576
  if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
2358
2577
  return { ok: true }
2359
2578
  }
2579
+ const loadedSet = new Set(plan.guard.referenceIds)
2360
2580
  for (const id of plan.activeIds) {
2361
- const archived = await codexThreadMutation(sock, 'thread/archive', id, fence)
2362
- 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)
2363
2597
  }
2364
2598
  let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
2365
2599
  const verifyDeadline = Date.now() + 30_000
@@ -2550,6 +2784,7 @@ export const piHarness: Harness = {
2550
2784
  events: PI_EVENTS,
2551
2785
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
2552
2786
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
2787
+ executionTrace: readSessionJsonlExecutionTrace,
2553
2788
  launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
2554
2789
  baseCmd: piBaseCmd,
2555
2790
  sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
@@ -2600,6 +2835,44 @@ export const piHeadlessHarness: Harness = {
2600
2835
  resumeArg: (rec) => `--session ${rec.session}`,
2601
2836
  }
2602
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
+
2603
2876
  export const opencodeHarness: Harness = {
2604
2877
  id: 'opencode',
2605
2878
  dispatchId: 'opencode',
@@ -2609,6 +2882,7 @@ export const opencodeHarness: Harness = {
2609
2882
  // socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
2610
2883
  ownsRendezvous: true,
2611
2884
  paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
2885
+ executionTrace: readLocalStoreExecutionTrace,
2612
2886
  launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
2613
2887
  baseCmd: opencodeBaseCmd,
2614
2888
  sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
@@ -2672,14 +2946,20 @@ export const opencodeHeadlessHarness: Harness = {
2672
2946
  }
2673
2947
 
2674
2948
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
2675
- 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]
2676
2950
 
2677
2951
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
2678
2952
  export const defaultHarness: Harness = claudeHarness
2679
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
+
2680
2960
  // resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
2681
2961
  export function harnessById(id: string): Harness {
2682
- const h = HARNESSES.find((x) => x.id === id)
2962
+ const h = harnessByIdOrNull(id)
2683
2963
  if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
2684
2964
  return h
2685
2965
  }