spexcode 0.5.8 → 0.6.0

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 (104) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +122 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/delivery-queue.ts +107 -0
  15. package/spec-cli/src/doctor.ts +0 -16
  16. package/spec-cli/src/gateway-auth.ts +0 -13
  17. package/spec-cli/src/gateway.ts +0 -22
  18. package/spec-cli/src/git.ts +151 -48
  19. package/spec-cli/src/graph.ts +28 -7
  20. package/spec-cli/src/graphCache.ts +253 -72
  21. package/spec-cli/src/graphDelta.ts +0 -13
  22. package/spec-cli/src/graphStream.ts +70 -37
  23. package/spec-cli/src/guide.ts +29 -6
  24. package/spec-cli/src/harness.ts +232 -248
  25. package/spec-cli/src/help.ts +27 -26
  26. package/spec-cli/src/host-resources.ts +11 -2
  27. package/spec-cli/src/index.ts +71 -95
  28. package/spec-cli/src/issues-cli.ts +276 -0
  29. package/spec-cli/src/issues.ts +15 -197
  30. package/spec-cli/src/layout.ts +78 -2
  31. package/spec-cli/src/lint.ts +5 -3
  32. package/spec-cli/src/localIssues.ts +22 -185
  33. package/spec-cli/src/loop-in.ts +78 -0
  34. package/spec-cli/src/materialize.ts +0 -42
  35. package/spec-cli/src/mentions.ts +49 -193
  36. package/spec-cli/src/opencode-headless.ts +7 -7
  37. package/spec-cli/src/opencode.ts +2 -5
  38. package/spec-cli/src/pi-headless.ts +5 -5
  39. package/spec-cli/src/pty-bridge.ts +4 -5
  40. package/spec-cli/src/reviewSnapshot.ts +1 -0
  41. package/spec-cli/src/reviews.ts +6 -1
  42. package/spec-cli/src/root-lru.ts +54 -0
  43. package/spec-cli/src/runtime-guard.ts +0 -9
  44. package/spec-cli/src/session-cursors.ts +91 -0
  45. package/spec-cli/src/session-follow.ts +182 -0
  46. package/spec-cli/src/session-timeline.ts +55 -127
  47. package/spec-cli/src/sessions.ts +235 -1021
  48. package/spec-cli/src/shim-runtime.ts +9 -16
  49. package/spec-cli/src/specs.ts +33 -10
  50. package/spec-cli/src/uninstall.ts +0 -9
  51. package/spec-cli/src/uploads.ts +240 -18
  52. package/spec-cli/src/worktree-sources.ts +0 -16
  53. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  54. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
  56. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  58. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  59. package/spec-cli/templates/spexcode.json +12 -0
  60. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  61. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  62. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  63. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  64. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  65. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  66. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  67. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  68. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  69. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  70. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  71. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  72. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  73. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  74. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  75. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  76. package/spec-dashboard/dist/index.html +2 -2
  77. package/spec-dashboard/src/session.js +1 -28
  78. package/spec-eval/src/cache.ts +0 -14
  79. package/spec-eval/src/cli.ts +26 -5
  80. package/spec-eval/src/evaltab.ts +67 -108
  81. package/spec-eval/src/filing.ts +0 -18
  82. package/spec-eval/src/freshness.ts +78 -32
  83. package/spec-eval/src/humanok.ts +0 -15
  84. package/spec-eval/src/scenariofresh.ts +8 -80
  85. package/spec-eval/src/scenarios.ts +16 -67
  86. package/spec-eval/src/sessioneval.ts +157 -78
  87. package/spec-eval/src/sidecar.ts +1 -70
  88. package/spec-eval/src/timeline.ts +0 -23
  89. package/spec-forge/src/cache.ts +34 -12
  90. package/spec-forge/src/cli.ts +0 -6
  91. package/spec-forge/src/drivers/github.ts +1 -12
  92. package/spec-forge/src/drivers/gitlab.ts +4 -19
  93. package/spec-forge/src/drivers.ts +0 -18
  94. package/spec-forge/src/links.ts +0 -4
  95. package/spec-forge/src/needs-eval.ts +0 -1
  96. package/spec-forge/src/port.ts +0 -12
  97. package/spec-forge/src/resident.ts +4 -20
  98. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  99. package/spec-cli/src/session-maintenance.ts +0 -751
  100. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  101. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  102. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  103. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  104. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -17,6 +17,7 @@ import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './la
17
17
  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
+ import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
20
21
 
21
22
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
22
23
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -56,6 +57,9 @@ export type SharedRuntimeDescriptor = {
56
57
  label: string
57
58
  pidFile: string
58
59
  receiptFile: string
60
+ // When a resident adapter has several immutable generations, this resolves the one exact descriptor a
61
+ // governed target owns. Product code consumes the selection without learning adapter-specific identities.
62
+ targetDescriptorKey?: (rec: HarnessLivenessRecord & { harnessSessionId?: string | null }) => string | null
59
63
  // Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
60
64
  // per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
61
65
  residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
@@ -110,7 +114,9 @@ export async function adapterLoadedReferenceState(
110
114
  const recordKeys = new Map<string, string[]>()
111
115
  for (const rec of records) {
112
116
  if (!rec.harnessSessionId) continue
113
- const keys = (harnessById(rec.harness || defaultHarness.id).sharedRuntimes?.(runtimeDir) ?? []).map((descriptor) => {
117
+ const harness = harnessById(rec.harness || defaultHarness.id)
118
+ const exactKey = harness.targetDescriptorKey?.(rec) ?? null
119
+ const keys = (harness.sharedRuntimes?.(runtimeDir) ?? []).filter((descriptor) => !exactKey || descriptor.key === exactKey).map((descriptor) => {
114
120
  descriptors.set(descriptor.key, descriptor)
115
121
  return descriptor.key
116
122
  })
@@ -272,14 +278,11 @@ export interface Harness {
272
278
  // token it registered for this record (session id, harness thread/generation, or null when unprovable);
273
279
  // product lifecycle code never branches on harness names to invent this identity.
274
280
  leafOwnerNeedle?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
275
- // deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
276
- // control socket an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
277
- // before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
278
- // expiry on a still-open connection is a busy-not-lost agent → optimistic ok (see replyViaSocket). codex:
279
- // JSON-RPC on the same app-server WebSocket the
281
+ // Poke a live session and report whether this immediate channel accepted the attempt. Claude-family adapters
282
+ // write one idempotent rendezvous reply; Codex uses JSON-RPC on the same app-server WebSocket the
280
283
  // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
281
284
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
282
- // Returns ok=false with a reason that propagates to the API.
285
+ // `ok=false` leaves the message OWED on the session's delivery queue, for a later pass to hand over.
283
286
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
284
287
  // Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
285
288
  // transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
@@ -311,10 +314,12 @@ export interface Harness {
311
314
  // Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
312
315
  // references and protect a sibling-owned control plane without learning harness command names.
313
316
  sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
314
- // the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
315
- // socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
316
- // the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
317
- // BEFORE delivering; absent on harnesses with no such state (codex delivery ignores the pane). claude: the
317
+ // Select the exact shared descriptor a record owns when an adapter has more than one resident generation.
318
+ // Null is an unproven binding and must fail closed before lifecycle mutation.
319
+ targetDescriptorKey?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
320
+ // The one pane state where this harness swallows an immediate poke: given live pane text, return the reason
321
+ // to skip that courtesy attempt or null when the pane can take it. sendText consults this after append;
322
+ // absent on harnesses whose poke ignores pane state. Claude's
318
323
  // TUI's sessions panel ("← for agents") enqueues an injected reply to the panel context and never drains it
319
324
  // — verified live: parsed + enqueued, no dequeue, no turn, daemon silent.
320
325
  deliveryBlockedBy?(paneText: string): string | null
@@ -338,26 +343,20 @@ export interface Harness {
338
343
  resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
339
344
  }
340
345
 
341
- // A prompt-dispatch outcome. `accepted` means the native control plane acknowledged the turn; `rejected` means
342
- // it definitely did not; `commit-unknown` means the request crossed a transport boundary but its native result
343
- // was lost, so replaying it could duplicate a turn. The latter is deliberately distinct from a normal rejection.
344
- export type DispatchOutcome = 'accepted' | 'rejected' | 'commit-unknown'
345
- // ok=true means delivery is confirmed at the layer that harness proves it: claude at
346
- // the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
347
- // still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
348
- // accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
349
- // (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
350
- // re-exports it for its existing importers.
351
- export type DispatchResult = { ok: boolean; outcome?: DispatchOutcome; error?: string }
346
+ // `ok` describes only this round's immediate adapter poke: the socket write, native controller request, or
347
+ // app-server turn request reached its channel. [[dispatch]] has already decided delivery at the timeline append,
348
+ // so an error means only that the reader will show the line at a later turn boundary. Defined here because this
349
+ // is the adapter result sessions.ts consumes for same-turn timing.
350
+ export type DispatchResult = { ok: boolean; error?: string }
352
351
  export type HarnessDeliveryRecord = {
353
352
  session: string
354
353
  worktreePath?: string
355
354
  harnessSessionId?: string | null
356
355
  runtimeDir?: string
357
356
  launchCmd?: string | null
358
- // Opaque caller-owned marker. Codex maps it to its native `clientUserMessageId`, while other adapters may
359
- // ignore it; product routing never needs to know which harness recognizes the marker.
360
- deliveryId?: string
357
+ // The message's timeline id ([[session-timeline]]). Codex maps it to its native `clientUserMessageId`, while
358
+ // other adapters may ignore it; product routing never needs to know which harness recognizes the marker.
359
+ mid?: string
361
360
  }
362
361
  // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
363
362
  // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
@@ -384,8 +383,20 @@ export type HarnessArtifacts = { skills: readonly string[]; agents: readonly str
384
383
  // `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
385
384
  // the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
386
385
  export const legacyRvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
386
+
387
+ // @@@ rendezvousSocketBase - macOS's TMPDIR commonly expands to /var/folders/<long-user-path>, leaving a
388
+ // UUID-scoped socket beyond its 104-byte sun_path limit. It can be created and observed by pathname but every
389
+ // connect fails EINVAL, which looks exactly like an unresponsive worker. Use a literal short /tmp spelling
390
+ // (not tmpdir(), whose resolved form is long on macOS) with the same private per-uid directory pattern as the
391
+ // Codex runtime socket. Existing launches retain their stamped old path through rvSock(); only new launches use
392
+ // this base.
393
+ const rendezvousSocketBase = () => {
394
+ const base = join('/tmp', `spexcode-rv-${process.getuid?.() ?? 0}`)
395
+ mkdirSync(base, { recursive: true, mode: 0o700 })
396
+ return base
397
+ }
387
398
  export const scopedRvSock = (id: string, dir = runtimeRoot()) =>
388
- join(tmpdir(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
399
+ join(rendezvousSocketBase(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
389
400
  const rvStamp = (id: string) => sessionArtifactPath(id, 'rv.path')
390
401
  export const rvSock = (id: string): string => {
391
402
  try { return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id) } catch { return legacyRvSock(id) }
@@ -451,9 +462,7 @@ export const rendezvousListening = (id: string, timeoutMs = 800): Promise<Listen
451
462
  // (idempotent mkdir) so every consumer — launch bake, liveness connect, delivery, tests — shares one creation
452
463
  // point. The `.pid`/`.log`/`.lock` files carry no sun_path limit and stay in `runtimeRoot`.
453
464
  export const codexAppServerSock = (dir = runtimeRoot()) => {
454
- const base = process.env.SPEXCODE_CODEX_SOCKET_DIR || join(tmpdir(), `spexcode-cx-${process.getuid?.() ?? 0}`)
455
- mkdirSync(base, { recursive: true, mode: 0o700 })
456
- return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
465
+ return codexGenerationSocketPath(dir)
457
466
  }
458
467
  export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
459
468
  export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
@@ -462,11 +471,11 @@ type CodexRuntimeGenerationProof = Readonly<{
462
471
  identity: VerifiedDetachedRuntime
463
472
  socket: Readonly<{ path: string; dev: number; ino: number }>
464
473
  }>
465
- function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
474
+ function codexRuntimeGenerationProof(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): CodexRuntimeGenerationProof | null {
466
475
  try {
467
- const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
468
- const detached = verifyDetachedRuntime(pid, codexAppServerReceipt(dir))
469
- const socketPath = codexAppServerSock(dir)
476
+ const pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim())
477
+ const detached = verifyDetachedRuntime(pid, endpoint.receiptFile)
478
+ const socketPath = endpoint.socketPath
470
479
  const socket = statSync(socketPath)
471
480
  if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
472
481
  return Object.freeze({
@@ -477,21 +486,31 @@ function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGeneratio
477
486
  }
478
487
  const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
479
488
  `${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
480
- function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
481
- const proof = codexRuntimeGenerationProof(dir)
489
+ function codexRuntimeGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): string | null {
490
+ const proof = codexRuntimeGenerationProof(dir, endpoint)
482
491
  return proof ? codexRuntimeGenerationToken(proof) : null
483
492
  }
484
493
 
485
- function codexMutationGeneration(dir = runtimeRoot()): string | null {
486
- const current = codexRuntimeGeneration(dir)
494
+ function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): string | null {
495
+ const current = codexRuntimeGeneration(dir, endpoint)
487
496
  if (current) return current
497
+ if (endpoint.id !== 'legacy') return null
488
498
  let pid: number
489
499
  try {
490
500
  pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
491
501
  if (!Number.isInteger(pid) || pid <= 0 || !statSync(codexAppServerSock(dir)).isSocket()) return null
492
502
  } catch { return null }
493
503
  if (!migrateLegacyDetachedRuntimeReceipt(pid, codexAppServerLegacyScope(dir), codexAppServerReceipt(dir))) return null
494
- return codexRuntimeGeneration(dir)
504
+ return codexRuntimeGeneration(dir, endpoint)
505
+ }
506
+
507
+ const codexDescriptorKey = (endpoint: CodexGenerationEndpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`
508
+
509
+ function codexEndpointForRecord(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, dir = runtimeRoot()): CodexGenerationEndpoint | null {
510
+ if (!rec.harnessSessionId) return null
511
+ const ledger = readCodexGenerationLedger(dir)
512
+ if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length) return legacyCodexGenerationEndpoint(dir)
513
+ return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId)
495
514
  }
496
515
 
497
516
  // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
@@ -501,94 +520,59 @@ function codexMutationGeneration(dir = runtimeRoot()): string | null {
501
520
  const PKG = fileURLToPath(new URL('..', import.meta.url))
502
521
  const SPEX = join(PKG, 'bin', 'spex.mjs')
503
522
 
504
- // @@@ replyViaSocket - ATOMIC parse-confirmed delivery. The daemon is SINGLE-CONNECTION: a new connect
505
- // `destroy()`s the previous socket, discarding any received-but-not-yet-parsed line with it and our own
506
- // `rendezvousListening` liveness probe IS such a connect, fired for every session on every board snapshot. So
507
- // the previous optimistic write (return ok once the reply line flushed) LOST prompts whenever a probe landed in
508
- // the write→parse window, a window that widens exactly when claude is busy mid-turn (field: dashboard messages
509
- // recorded `sent` with no trace in the claude transcript; measured 2/10 lost under a 20ms probe hammer). The
510
- // daemon parses a chunk's complete lines in ONE synchronous loop, so writing `{type:reply}` + `{type:repaint}`
511
- // as ONE chunk makes the pair indivisible — a kick loses BOTH or NEITHER — and the outcome decidable from this
512
- // connection alone:
513
- // `repaint-done` arrives → the reply line before it was parsed (in-order barrier) → ok, CONFIRMED.
514
- // 'close' before it → the chunk was never parsed (kicked by a concurrent connect) → resolve kicked:true
515
- // so deliverViaRendezvous RESENDS — a proven loss, so the retry cannot duplicate.
516
- // wall expires, conn open → a busy event loop is DELAYING, not losing (the 2500ms-wall lesson: ack absence is
517
- // NOT non-delivery) → ok, OPTIMISTIC — never a false failure on a busy worker.
518
- // `reply-rejected`/`auth-rejected`/`shutting-down` → loud failure, not retried.
519
- // Other daemon lines (heartbeat, state patches) are ignored. Never throws.
520
- type ReplyOutcome = DispatchResult & { kicked?: boolean }
521
- function replyViaSocket(sock: string, text: string, wallMs = 10_000): Promise<ReplyOutcome> {
523
+ // The timeline is the message's copy, so rendezvous needs no receipt protocol. It writes one idempotent poke
524
+ // 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> {
522
526
  return new Promise((resolve) => {
523
527
  let settled = false
524
528
  let c: ReturnType<typeof createConnection>
525
- const done = (r: ReplyOutcome) => {
529
+ const done = (r: DispatchResult) => {
526
530
  if (settled) return
527
531
  settled = true
528
- clearTimeout(wall)
529
- try { c?.destroy() } catch { /* */ }
532
+ if (!r.ok) try { c?.destroy() } catch { /* */ }
530
533
  resolve(r)
531
534
  }
532
- const wall = setTimeout(() => done({ ok: true }), wallMs)
533
535
  try {
534
536
  c = createConnection({ path: sock })
535
537
  } catch (e) {
536
538
  done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
537
539
  return
538
540
  }
539
- // ECONNRESET/EPIPE are the KICK surfacing as an error: the daemon destroy()s the previous connection the
540
- // moment a new one connects, and destroying a socket with OUR chunk still unread raises RST — whereas a
541
- // parsed chunk answers repaint-done (readable even after a later close) before any clean FIN. So both codes
542
- // PROVE the chunk was never parsed → retryable, same as the clean pre-parse close. ECONNREFUSED/ENOENT
543
- // (daemon gone) stay loud.
544
541
  c.on('error', (e: NodeJS.ErrnoException) => {
545
542
  const code = e?.code || String(e)
546
- const kicked = code === 'ECONNRESET' || code === 'EPIPE'
547
- done({ ok: false, ...(kicked ? { kicked } : {}), error: `rendezvous socket error: ${code} — prompt NOT delivered` })
548
- })
549
- c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection was closed before the daemon parsed the prompt (kicked by a concurrent connect)' }))
550
- c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
551
- let buf = ''
552
- c.on('data', (d) => {
553
- buf += d.toString('utf8')
554
- let nl
555
- while ((nl = buf.indexOf('\n')) >= 0) {
556
- const line = buf.slice(0, nl)
557
- buf = buf.slice(nl + 1)
558
- let type = ''
559
- try { type = (JSON.parse(line) as { type?: string })?.type ?? '' } catch { continue }
560
- if (type === 'repaint-done') return done({ ok: true })
561
- if (type === 'reply-rejected' || type === 'auth-rejected') return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` })
562
- if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' })
563
- }
543
+ done({ ok: false, error: `rendezvous socket error: ${code}` })
564
544
  })
545
+ c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }))
546
+ c.on('connect', () => c.write(
547
+ JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) }) + '\n',
548
+ (error) => {
549
+ if (error) return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` })
550
+ c.end()
551
+ done({ ok: true })
552
+ },
553
+ ))
565
554
  })
566
555
  }
567
- // claude's deliver: the pre-write LIVENESS gate — fail loud BEFORE attempting the socket if it isn't there (a
568
- // clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent
569
- // alive). Then the atomic parse-confirmed write; a KICKED outcome is a proven whole-chunk loss, so it resends
570
- // (bounded attempts + jitter so re-collision with the probe cadence is unlikely); exhausted retries fail loud.
571
- const DELIVER_ATTEMPTS = 3
572
- export async function deliverViaRendezvous(id: string, text: string, wallMs?: number): Promise<DispatchResult> {
556
+ const POKE_ATTEMPTS = 2
557
+ export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
573
558
  const sock = rvSock(id)
574
- if (!existsSync(sock)) return { ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` }
575
- let last: ReplyOutcome = { ok: false, error: 'not attempted' }
576
- for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt++) {
577
- last = await replyViaSocket(sock, text, wallMs)
578
- if (last.ok || !last.kicked) return { ok: last.ok, ...(last.error ? { error: last.error } : {}) }
579
- await new Promise((r) => setTimeout(r, 60 + Math.random() * 140))
559
+ let last: DispatchResult = { ok: false, error: 'not attempted' }
560
+ for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
561
+ last = await replyViaSocket(sock, text, mid)
562
+ if (last.ok) return last
580
563
  }
581
- return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× prompt NOT delivered, retry the send` }
564
+ return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` }
582
565
  }
583
566
 
584
567
  export async function deliverViaSocketOrWake(
585
568
  id: string,
586
569
  text: string,
570
+ mid: string | undefined,
587
571
  coldWake: () => Promise<DispatchResult>,
588
572
  unprovenError: string,
589
573
  ): Promise<DispatchResult> {
590
574
  const probe = await rendezvousListening(id)
591
- if (probe === 'live') return deliverViaRendezvous(id, text)
575
+ if (probe === 'live') return deliverViaRendezvous(id, text, mid)
592
576
  if (probe === 'unproven') return { ok: false, error: unprovenError }
593
577
  return coldWake()
594
578
  }
@@ -708,18 +692,8 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
708
692
  // fresh app-server (where codex-launch never runs) still trusts our hooks. Hence the flag lives on the resume
709
693
  // TUI, never on the app-server invocation. Guarded against a double-flag when an env override already carries it.
710
694
  const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
711
- const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
712
- const pid = codexAppServerPid(dir)
713
- const receipt = codexAppServerReceipt(dir)
714
- const log = join(dir, 'codex-app-server.log')
715
- const lock = join(dir, 'codex-app-server.lock')
716
695
  const script = [
717
696
  `dir=${shQuote(dir)}`,
718
- `sock=${shQuote(sock)}`,
719
- `pid=${shQuote(pid)}`,
720
- `receipt=${shQuote(receipt)}`,
721
- `log=${shQuote(log)}`,
722
- `lock=${shQuote(lock)}`,
723
697
  // codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
724
698
  // WE already hold the launcher's real cmd here (it drives the app-server + resume TUI + tuiBypass above), so
725
699
  // pin it into the environment the codex-launch child inherits. Without this the child falls back to a bare
@@ -727,51 +701,14 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
727
701
  // probes the WRONG binary — deciding "no --dangerously-bypass-hook-trust support" and silently dropping the
728
702
  // thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
729
703
  `export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
730
- 'mkdir -p "$dir"',
731
- 'mkdir -p -m 700 "$(dirname "$sock")"', // the socket base (owned tmp subdir or the SPEXCODE_CODEX_SOCKET_DIR override) re-created here because a tmp cleaner may have wiped it since the bake; NEVER bare /tmp (codex EPERMs binding there on hardened hosts, github#30)
732
- // self-heal: the pre-fix flock design left an orphaned `codex-app-server.lock` FILE; the mkdir mutex now
733
- // uses `"$lock.d"`, so drop that dead residue on already-run deployments (harmless if absent).
734
- 'rm -f "$lock"',
735
- // POSIX-portable mutex: mkdir is atomic on every POSIX fs, so it serializes the check-and-start with NO
736
- // dependency on util-linux `flock` (absent on macOS where the old flock path failed the whole app-server
737
- // bootstrap, leaving the pane at the shell). Spin on `mkdir "$lock.d"` with a bounded wait; after ~10s
738
- // (200 * 0.05s, safely above the ~5s socket-wait a legit holder needs) treat the dir as orphaned by a dead
739
- // launcher and clear it, so a stale lock can never deadlock a launch. Held ONLY across the check-and-start,
740
- // released immediately after. Unlike flock (held until every fd on its open file description closes) a mkdir
741
- // lock has no inherited-fd hazard, so the long-lived daemon can't pin it — no fd-9 gymnastics needed.
742
- 'lockd="$lock.d"',
743
- '_lk=0',
744
- 'until mkdir "$lockd" 2>/dev/null; do',
745
- ' _lk=$((_lk+1)); [ "$_lk" -ge 200 ] && { rm -rf "$lockd" 2>/dev/null; _lk=0; }',
746
- ' sleep 0.05',
747
- 'done',
748
- 'if [ -S "$sock" ] && [ -s "$pid" ] && ! kill -0 "$(cat "$pid")" 2>/dev/null; then rm -f "$sock"; fi',
749
- 'if [ ! -S "$sock" ]; then',
750
- // The app-server is a per-PROJECT daemon SHARED across every worktree's threads, so it must run in a STABLE
751
- // cwd — the runtime dir "$dir", NOT the launch.sh's transient worktree. A daemon started inside a worktree
752
- // keeps that worktree as its process cwd for its whole life; when the session closes and the worktree is
753
- // removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
754
- // `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
755
- // until the daemon is killed. Running it from "$dir" (which never gets deleted) makes it deletion-proof.
756
- // For the SAME reason it must carry no session IDENTITY: it is started by whichever session happened to
757
- // launch first and then serves every later thread, whose tool shells inherit its env — so a baked
758
- // SPEXCODE_SESSION_ID (or any adapter's `sessionEnvVar`) is a stale lie for every session but one, and
759
- // still a lie after that session closes and its record is swept (measured: daemons here running for days
760
- // under a long-gone session's id). Everything downstream that resolves identity from the env then
761
- // mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
762
- // inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
763
- // The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
764
- // a boundary: the Codex Node launcher reset signal handling and died with the tmux pane. The internal helper
765
- // uses child_process detached=true, then the process adapter publishes a private receipt only after proving
766
- // exact PID/start + PGID (and Linux SID). Every consumer re-verifies that same receipt.
767
- ` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$receipt" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
768
- ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
704
+ // 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.
707
+ 'if [ "$1" = "--resume" ]; then',
708
+ ` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" )" || exit 1`,
709
+ 'else',
710
+ ` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
769
711
  'fi',
770
- 'rmdir "$lockd" 2>/dev/null',
771
- // The delegated bearer arrived only through fd 9. The shared-spawn helper consumed it (or the already-live
772
- // socket made it unnecessary); close and scrub the channel before the per-session Codex client starts.
773
- '[ "${SPEXCODE_MAINTENANCE_DELEGATE_FD:-}" != "9" ] || exec 9<&-',
774
- 'unset SPEXCODE_MAINTENANCE_DELEGATE_FD SPEXCODE_MAINTENANCE_SESSION_ID',
775
712
  // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
776
713
  // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
777
714
  // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
@@ -867,7 +804,15 @@ export function codexTurnFailureObserver(
867
804
  ): FailureSubscription {
868
805
  const threadId = rec.harnessSessionId
869
806
  if (!threadId) return { close: () => {}, closed: Promise.resolve(null) }
870
- const sock = codexAppServerSock(rec.runtimeDir || runtimeRoot())
807
+ const runtimeDir = rec.runtimeDir || runtimeRoot()
808
+ const endpoint = codexEndpointForRecord(rec, runtimeDir)
809
+ if (!endpoint) {
810
+ return {
811
+ close: () => {},
812
+ closed: Promise.resolve(`Codex turn observer refused: no exact generation binding for session ${rec.session}`),
813
+ }
814
+ }
815
+ const sock = endpoint.socketPath
871
816
  const conn: Socket = createConnection(sock)
872
817
  const frames: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
873
818
  let upgraded = false, settled = false
@@ -959,9 +904,9 @@ export function codexTurnFailureObserver(
959
904
 
960
905
  // Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
961
906
  // defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
962
- type CodexGenerationFence = { dir: string; generation: string }
907
+ type CodexGenerationFence = { dir: string; endpoint: CodexGenerationEndpoint; generation: string }
963
908
  function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive', threadId: string, fence?: CodexGenerationFence): Promise<{ ok: true } | { ok: false; error: string }> {
964
- const generationError = () => fence && codexRuntimeGeneration(fence.dir) !== fence.generation
909
+ const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
965
910
  ? `Codex ${method} refused because the shared app-server generation changed`
966
911
  : null
967
912
  const before = generationError()
@@ -978,7 +923,7 @@ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/un
978
923
  resolve(r)
979
924
  }
980
925
  // thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
981
- // legitimate late response is not turned into an early commit-unknown race.
926
+ // legitimate late response is not turned into an early timeout race.
982
927
  const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
983
928
  conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
984
929
  conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${method}` }) })
@@ -1093,6 +1038,8 @@ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true;
1093
1038
  return result.ok ? { ok: true, referenceIds: result.ids } : result
1094
1039
  }
1095
1040
 
1041
+ const CODEX_TARGET_TURN_CENSUS_MS = 15_000
1042
+
1096
1043
  function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }> {
1097
1044
  return new Promise((resolve) => {
1098
1045
  const conn: Socket = createConnection(sock)
@@ -1102,23 +1049,25 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
1102
1049
  if (settled) return
1103
1050
  settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
1104
1051
  }
1105
- const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} read timed out after 5000ms` }), 5000)
1106
- conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} read failed: ${rpcError(error)}` }))
1107
- conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` }) })
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)
1053
+ conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }))
1054
+ conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }) })
1108
1055
  const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
1109
1056
  conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
1110
1057
  const handle = (json: string) => {
1111
1058
  let message: JsonRpc
1112
1059
  try { message = JSON.parse(json) } catch { return }
1113
- if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} read failed: ${message.error.message || JSON.stringify(message.error)}` })
1060
+ if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${message.error.message || JSON.stringify(message.error)}` })
1114
1061
  if (message.id === 1 && message.result) {
1115
1062
  send({ method: 'initialized', params: {} })
1116
- return send({ id: 2, method: 'thread/read', params: { threadId, includeTurns: true } })
1063
+ // The guard needs only the current turn. `thread/read {includeTurns:true}` materializes the
1064
+ // entire persisted history, so an old but otherwise healthy thread can time out before close.
1065
+ return send({ id: 2, method: 'thread/turns/list', params: { threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } })
1117
1066
  }
1118
1067
  if (message.id !== 2 || !message.result) return
1119
- const turns = (message.result as { thread?: { turns?: unknown } }).thread?.turns
1068
+ const turns = (message.result as { data?: unknown }).data
1120
1069
  if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
1121
- return done({ ok: true, turnPresence: activeTurnIdFromThread(message.result) ? 'active' : 'idle' })
1070
+ return done({ ok: true, turnPresence: turns.some((turn) => turn && typeof turn === 'object' && (turn as { status?: unknown }).status === 'inProgress') ? 'active' : 'idle' })
1122
1071
  }
1123
1072
  conn.on('data', (chunk: Buffer) => {
1124
1073
  fs.buf = Buffer.concat([fs.buf, chunk])
@@ -1126,10 +1075,10 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
1126
1075
  const i = fs.buf.indexOf('\r\n\r\n')
1127
1076
  if (i < 0) return
1128
1077
  const head = fs.buf.slice(0, i).toString('utf8')
1129
- if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused target thread ${threadId} read: ${head.split('\r\n')[0]}` })
1078
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused target thread ${threadId} turn census: ${head.split('\r\n')[0]}` })
1130
1079
  upgraded = true; fs.buf = fs.buf.slice(i + 4); send(wsInitialize)
1131
1080
  }
1132
- if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` })
1081
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` })
1133
1082
  })
1134
1083
  })
1135
1084
  }
@@ -1168,10 +1117,10 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
1168
1117
  })
1169
1118
  }
1170
1119
 
1171
- async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
1172
- const generationBefore = codexMutationGeneration(dir)
1120
+ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeMutationGuard> {
1121
+ const generationBefore = codexMutationGeneration(dir, endpoint)
1173
1122
  if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
1174
- const sock = codexAppServerSock(dir)
1123
+ const sock = endpoint.socketPath
1175
1124
  const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
1176
1125
  codexLoadedReferenceIds(sock),
1177
1126
  codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
@@ -1190,7 +1139,7 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()):
1190
1139
  if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
1191
1140
  targetTurnPresence = target.turnPresence
1192
1141
  }
1193
- if (codexRuntimeGeneration(dir) !== generationBefore)
1142
+ if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
1194
1143
  return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
1195
1144
  return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
1196
1145
  }
@@ -1201,6 +1150,7 @@ type CodexColdPlan = Readonly<{
1201
1150
  kind: 'codex-cold-subtree-v1'
1202
1151
  threadId: string
1203
1152
  generation: string
1153
+ endpoint: CodexGenerationEndpoint
1204
1154
  guard: SharedRuntimeMutationGuard
1205
1155
  descendantIds: readonly string[]
1206
1156
  parentEdges: readonly (readonly [string, string])[]
@@ -1220,16 +1170,22 @@ const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
1220
1170
  if (!value || typeof value !== 'object') return false
1221
1171
  const plan = value as Partial<CodexColdPlan>
1222
1172
  return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
1223
- typeof plan.generation === 'string' && Array.isArray(plan.descendantIds) &&
1173
+ typeof plan.generation === 'string' && isEndpointLike(plan.endpoint) && Array.isArray(plan.descendantIds) &&
1224
1174
  Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
1225
1175
  Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
1226
1176
  }
1227
1177
 
1228
- async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
1229
- const generation = expectedGeneration ?? codexMutationGeneration(dir)
1230
- if (!generation || codexRuntimeGeneration(dir) !== generation)
1178
+ function isEndpointLike(value: unknown): value is CodexGenerationEndpoint {
1179
+ return !!value && typeof value === 'object' && typeof (value as CodexGenerationEndpoint).id === 'string' &&
1180
+ typeof (value as CodexGenerationEndpoint).pidFile === 'string' && typeof (value as CodexGenerationEndpoint).receiptFile === 'string' &&
1181
+ typeof (value as CodexGenerationEndpoint).socketPath === 'string'
1182
+ }
1183
+
1184
+ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
1185
+ const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint)
1186
+ if (!generation || codexRuntimeGeneration(dir, endpoint) !== generation)
1231
1187
  return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
1232
- const sock = codexAppServerSock(dir)
1188
+ const sock = endpoint.socketPath
1233
1189
  const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
1234
1190
  codexLoadedReferenceIds(sock),
1235
1191
  codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
@@ -1237,7 +1193,7 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1237
1193
  codexThreadList(sock, { archived: true, sourceKinds: [] }),
1238
1194
  codexThreadList(sock, { archived: false, sourceKinds: [] }),
1239
1195
  ])
1240
- if (codexRuntimeGeneration(dir) !== generation)
1196
+ if (codexRuntimeGeneration(dir, endpoint) !== generation)
1241
1197
  return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
1242
1198
  if (!loaded.ok) return { ok: false, reason: loaded.error }
1243
1199
  if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
@@ -1290,7 +1246,7 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1290
1246
  const loadedSet = new Set(loaded.referenceIds)
1291
1247
  const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
1292
1248
  const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
1293
- if (codexRuntimeGeneration(dir) !== generation)
1249
+ if (codexRuntimeGeneration(dir, endpoint) !== generation)
1294
1250
  return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
1295
1251
  for (const { id, state } of turnStates) {
1296
1252
  if (!state.ok) return { ok: false, reason: state.error }
@@ -1316,6 +1272,7 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1316
1272
  kind: 'codex-cold-subtree-v1',
1317
1273
  threadId,
1318
1274
  generation,
1275
+ endpoint,
1319
1276
  guard,
1320
1277
  descendantIds: Object.freeze([...descendantIds]),
1321
1278
  parentEdges: Object.freeze(parentEdges),
@@ -1354,7 +1311,8 @@ async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSe
1354
1311
  if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
1355
1312
  return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
1356
1313
  const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
1357
- const archived = await codexThreadMutation(codexAppServerSock(dir), 'thread/archive', threadId, { dir, generation })
1314
+ const legacy = legacyCodexGenerationEndpoint(dir)
1315
+ const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation })
1358
1316
  if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}` }
1359
1317
  const after = await codexColdPreflight(threadId, dir, generation)
1360
1318
  const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
@@ -1379,13 +1337,15 @@ async function codexMutationGuard(
1379
1337
  threadId: string,
1380
1338
  dir = runtimeRoot(),
1381
1339
  opts: { coldReceipt?: unknown } = {},
1340
+ endpoint = legacyCodexGenerationEndpoint(dir),
1382
1341
  ): Promise<SharedRuntimeMutationGuard> {
1383
- if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir)
1342
+ if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir, endpoint)
1384
1343
  if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
1385
1344
  return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
1386
- const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation)
1345
+ if (opts.coldReceipt.endpoint.id !== endpoint.id) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt names a different generation' }
1346
+ const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation, endpoint)
1387
1347
  if (!current.ok) {
1388
- const guard = await codexTargetMutationGuard(threadId, dir)
1348
+ const guard = await codexTargetMutationGuard(threadId, dir, endpoint)
1389
1349
  return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
1390
1350
  }
1391
1351
  const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
@@ -1401,16 +1361,16 @@ async function codexMutationGuard(
1401
1361
  }
1402
1362
 
1403
1363
  async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
1404
- if (codexRuntimeGeneration(dir) !== plan.generation)
1364
+ if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
1405
1365
  return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1406
- const sock = codexAppServerSock(dir)
1366
+ const sock = plan.endpoint.socketPath
1407
1367
  const [activeBefore, archivedBefore] = await Promise.all([
1408
1368
  codexThreadList(sock, { archived: false, sourceKinds: [] }),
1409
1369
  codexThreadList(sock, { archived: true, sourceKinds: [] }),
1410
1370
  ])
1411
1371
  if (!activeBefore.ok || !archivedBefore.ok)
1412
1372
  return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
1413
- if (codexRuntimeGeneration(dir) !== plan.generation)
1373
+ if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
1414
1374
  return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1415
1375
  const activeSet = new Set(activeBefore.ids)
1416
1376
  const archivedSet = new Set(archivedBefore.ids)
@@ -1418,7 +1378,7 @@ async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): P
1418
1378
  return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
1419
1379
  if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
1420
1380
  return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
1421
- const fence = { dir, generation: plan.generation }
1381
+ const fence = { dir, endpoint: plan.endpoint, generation: plan.generation }
1422
1382
  const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
1423
1383
  for (const id of restoreIds) {
1424
1384
  const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
@@ -1428,7 +1388,7 @@ async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): P
1428
1388
  codexThreadList(sock, { archived: false, sourceKinds: [] }),
1429
1389
  codexThreadList(sock, { archived: true, sourceKinds: [] }),
1430
1390
  ])
1431
- const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir) === plan.generation &&
1391
+ const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir, plan.endpoint) === plan.generation &&
1432
1392
  plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
1433
1393
  plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
1434
1394
  return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
@@ -1485,19 +1445,19 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
1485
1445
  // Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
1486
1446
  // they are never treated as references by themselves. A loaded thread is a control-plane reference and its
1487
1447
  // fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
1488
- export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRuntimeProbe> {
1489
- const sock = codexAppServerSock(dir)
1448
+ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeProbe> {
1449
+ const sock = endpoint.socketPath
1490
1450
  return (async () => {
1491
1451
  // File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
1492
1452
  // only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
1493
1453
  // root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
1494
1454
  let pid = 0
1495
- try { pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim()) } catch { /* absent/stale */ }
1455
+ try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) } catch { /* absent/stale */ }
1496
1456
  const pidLive = pid > 0 && !!processStartToken(pid)
1497
1457
  const listener = await listenerAt(sock, 800)
1498
1458
  if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
1499
1459
  if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
1500
- const generation = codexRuntimeGeneration(dir)
1460
+ const generation = codexRuntimeGeneration(dir, endpoint)
1501
1461
  if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
1502
1462
  return new Promise<SharedRuntimeProbe>((resolve) => {
1503
1463
  const conn: Socket = createConnection(sock)
@@ -1516,7 +1476,7 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
1516
1476
  settled = true
1517
1477
  clearTimeout(timer)
1518
1478
  try { conn.destroy() } catch { /* */ }
1519
- resolve(result.healthy && codexRuntimeGeneration(dir) !== generation
1479
+ resolve(result.healthy && codexRuntimeGeneration(dir, endpoint) !== generation
1520
1480
  ? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
1521
1481
  : result)
1522
1482
  }
@@ -1690,7 +1650,6 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1690
1650
  let buf = Buffer.alloc(0), upgraded = false, settled = false
1691
1651
  let fragOp = 0, fragBuf = Buffer.alloc(0)
1692
1652
  let steering = false // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
1693
- let injected = false
1694
1653
  const done = (r: DispatchResult) => {
1695
1654
  if (settled) return
1696
1655
  settled = true
@@ -1698,7 +1657,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1698
1657
  try { conn.destroy() } catch { /* */ }
1699
1658
  resolve(r)
1700
1659
  }
1701
- const unresolved = (error: string) => done({ ok: false, outcome: injected ? 'commit-unknown' : 'rejected', error })
1660
+ const unresolved = (error: string) => done({ ok: false, error })
1702
1661
  const timer = setTimeout(() => unresolved(`codex app-server did not confirm the turn within ${codexTurnConfirmMs()}ms`), codexTurnConfirmMs())
1703
1662
  conn.on('error', (e) => unresolved(`codex app-server connection failed: ${rpcError(e)}`))
1704
1663
  conn.on('close', () => unresolved('codex app-server closed the connection before the turn was confirmed'))
@@ -1715,7 +1674,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1715
1674
  return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId))
1716
1675
  if (m.id === 3) // thread not readable yet (a freshly-started thread is "not materialized
1717
1676
  return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId)) // before its first user message") → just turn/start
1718
- return done({ ok: false, outcome: 'rejected', error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
1677
+ return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
1719
1678
  }
1720
1679
  // JSON-RPC initialization is ordered. Under a quiet server the premature notification happened to win;
1721
1680
  // under shared app-server load it was ignored and every later turn waited until the old 5s wall expired.
@@ -1723,16 +1682,15 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1723
1682
  if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
1724
1683
  const loaded = (m.result as { data?: unknown })?.data
1725
1684
  if (Array.isArray(loaded) && !loaded.includes(threadId))
1726
- return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — prompt NOT delivered` })
1685
+ return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — immediate poke not accepted` })
1727
1686
  return send(hs[3]) // thread is live → read it to decide steer-vs-start
1728
1687
  }
1729
1688
  if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
1730
1689
  const turnId = activeTurnIdFromThread(m.result)
1731
1690
  steering = !!turnId
1732
- injected = true
1733
1691
  return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)) // id 4: turn/steer the live turn, or turn/start
1734
1692
  }
1735
- if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true, outcome: 'accepted' }) // steer/start accepted → the model has the message
1693
+ if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true }) // steer/start accepted → the model has the message
1736
1694
  }
1737
1695
  const drainFrames = () => {
1738
1696
  for (;;) {
@@ -1811,26 +1769,29 @@ export async function waitForCodexRollout(threadId: string, timeoutMs = 20000):
1811
1769
  }
1812
1770
  }
1813
1771
 
1814
- // codex's deliver: use the Codex app-server JSON-RPC channel that also powers rich clients, never TUI typing.
1772
+ // codex's immediate poke uses the app-server JSON-RPC channel that also powers rich clients, never TUI typing.
1815
1773
  // The visible TUI is launched against the same project app-server Unix socket, so this injects into the same
1816
1774
  // thread the pane is showing — steering an in-progress turn or starting one if idle. A missing captured thread
1817
- // id or socket is a loud failure; there is no tmux send-keys fallback because that reports "typed", not "accepted".
1775
+ // id or socket makes this poke fail; there is no tmux send-keys fallback because that reports "typed", not "accepted".
1818
1776
  const pexec = promisify(execFile)
1819
1777
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
1820
1778
  async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult> {
1821
1779
  // the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
1822
1780
  // the record picks out THIS session's thread.
1823
- const sock = codexAppServerSock(rec.runtimeDir)
1824
- if (!existsSync(sock)) return { ok: false, error: `no Codex app-server socket for this project — prompt NOT delivered` }
1781
+ const runtimeDir = rec.runtimeDir ?? runtimeRoot()
1782
+ const endpoint = rec.harnessSessionId ? codexEndpointForRecord(rec, runtimeDir) : currentCodexGeneration(runtimeDir)
1783
+ if (!endpoint) return { ok: false, error: `no exact Codex generation binding for session ${rec.session} — immediate poke unavailable` }
1784
+ const sock = endpoint.socketPath
1785
+ if (!existsSync(sock)) return { ok: false, error: `no Codex app-server socket for this project — immediate poke unavailable` }
1825
1786
  // use the backend-owned thread id stored at launch; fall back to reading the one loaded thread only if it's
1826
1787
  // empty (a pre-existing session from before the id was stored).
1827
1788
  let threadId = rec.harnessSessionId
1828
1789
  if (!threadId) {
1829
1790
  const r = await codexThreadId(sock)
1830
- if (!r.ok) return { ok: false, error: `${r.error} — prompt NOT delivered` }
1791
+ if (!r.ok) return { ok: false, error: `${r.error} — immediate poke unavailable` }
1831
1792
  threadId = r.threadId
1832
1793
  }
1833
- return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.deliveryId)
1794
+ return sendCodexAppServerTurn(sock, threadId!, text, rec.worktreePath, rec.mid)
1834
1795
  }
1835
1796
 
1836
1797
  // idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
@@ -2179,12 +2140,12 @@ export const claudeHarness: Harness = {
2179
2140
  // dead-pane-reads-working bug). See rendezvousListening.
2180
2141
  liveness: socketListenerLiveness,
2181
2142
  leafOwnerNeedle: (rec) => rec.session,
2182
- deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
2143
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2183
2144
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2184
2145
  coldRuntime: async () => ({ ok: true }),
2185
- // the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
2186
- // and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
2187
- // the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
2146
+ // The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
2147
+ // (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so skip this courtesy
2148
+ // poke and leave the timeline reader to show the message. Matched on the panel's own
2188
2149
  // strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
2189
2150
  // across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
2190
2151
  deliveryBlockedBy: (paneText) =>
@@ -2220,6 +2181,36 @@ export const claudeHeadlessHarness: Harness = {
2220
2181
  deliveryBlockedBy: undefined,
2221
2182
  }
2222
2183
 
2184
+ function codexRuntimeDescriptor(endpoint: CodexGenerationEndpoint, runtimeDir: string): SharedRuntimeDescriptor {
2185
+ return {
2186
+ key: codexDescriptorKey(endpoint),
2187
+ label: endpoint.id === 'legacy' ? 'Codex app-server' : `Codex app-server ${endpoint.id.slice(0, 18)}`,
2188
+ pidFile: endpoint.pidFile,
2189
+ receiptFile: endpoint.receiptFile,
2190
+ residency: async () => {
2191
+ let pid = 0
2192
+ try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) } catch { /* stale/missing pid */ }
2193
+ const pidLive = pid > 0 && !!processStartToken(pid)
2194
+ const listener = await listenerAt(endpoint.socketPath, 800)
2195
+ if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
2196
+ if (pidLive && (!codexRuntimeGeneration(runtimeDir, endpoint) || listener !== 'live'))
2197
+ return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
2198
+ if (!pidLive || listener !== 'live')
2199
+ return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
2200
+ const result = await codexLoadedReferenceIds(endpoint.socketPath)
2201
+ return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
2202
+ },
2203
+ mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts, endpoint),
2204
+ probe: () => codexSharedRuntimeProbe(runtimeDir, endpoint),
2205
+ }
2206
+ }
2207
+
2208
+ function codexRuntimeDescriptors(runtimeDir: string): SharedRuntimeDescriptor[] {
2209
+ const endpoints = codexGenerationEndpoints(runtimeDir)
2210
+ return (endpoints.length ? endpoints : [legacyCodexGenerationEndpoint(runtimeDir)])
2211
+ .map((endpoint) => codexRuntimeDescriptor(endpoint, runtimeDir))
2212
+ }
2213
+
2223
2214
  export const codexHarness: Harness = {
2224
2215
  id: 'codex',
2225
2216
  dispatchId: 'codex',
@@ -2292,14 +2283,20 @@ export const codexHarness: Harness = {
2292
2283
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
2293
2284
  observeTurnFailures: codexTurnFailureObserver,
2294
2285
  cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
2286
+ targetDescriptorKey: (rec) => {
2287
+ const endpoint = codexEndpointForRecord(rec)
2288
+ return endpoint ? codexDescriptorKey(endpoint) : null
2289
+ },
2295
2290
  coldRetirementPreflight: async (rec) => {
2296
2291
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2297
2292
  const threadId = rec.harnessSessionId
2298
2293
  const dir = runtimeRoot()
2299
- const generationBefore = codexRuntimeGeneration(dir)
2294
+ const endpoint = codexEndpointForRecord(rec, dir)
2295
+ if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2296
+ const generationBefore = codexRuntimeGeneration(dir, endpoint)
2300
2297
  if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
2301
- const result = await codexColdPreflight(threadId, dir, generationBefore)
2302
- if (codexRuntimeGeneration(dir) !== generationBefore)
2298
+ const result = await codexColdPreflight(threadId, dir, generationBefore, endpoint)
2299
+ if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
2303
2300
  return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
2304
2301
  if (!result.ok) return result
2305
2302
  if (!result.alreadyCold)
@@ -2308,21 +2305,25 @@ export const codexHarness: Harness = {
2308
2305
  },
2309
2306
  coldPreflight: async (rec) => {
2310
2307
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2311
- return codexColdPreflight(rec.harnessSessionId)
2308
+ const endpoint = codexEndpointForRecord(rec)
2309
+ return endpoint ? codexColdPreflight(rec.harnessSessionId, runtimeRoot(), undefined, endpoint)
2310
+ : { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2312
2311
  },
2313
2312
  coldRuntime: async (rec, suppliedReceipt) => {
2314
2313
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2315
2314
  const threadId = rec.harnessSessionId
2316
2315
  const dir = runtimeRoot()
2317
- const sock = codexAppServerSock(dir)
2318
- const generationBefore = codexRuntimeGeneration(dir)
2316
+ const endpoint = codexEndpointForRecord(rec, dir)
2317
+ if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2318
+ const sock = endpoint.socketPath
2319
+ const generationBefore = codexRuntimeGeneration(dir, endpoint)
2319
2320
  if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
2320
2321
  if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
2321
2322
  return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
2322
2323
  const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
2323
- if (frozenPlan && frozenPlan.generation !== generationBefore)
2324
+ if (frozenPlan && (frozenPlan.generation !== generationBefore || frozenPlan.endpoint.id !== endpoint.id))
2324
2325
  return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
2325
- const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore)
2326
+ const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore, endpoint)
2326
2327
  if (!preflight.ok) return preflight
2327
2328
  const plan = frozenPlan ?? preflight.receipt
2328
2329
  if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
@@ -2330,12 +2331,12 @@ export const codexHarness: Harness = {
2330
2331
  !sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
2331
2332
  !sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
2332
2333
  return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
2333
- if (codexRuntimeGeneration(dir) !== plan.generation)
2334
+ if (codexRuntimeGeneration(dir, endpoint) !== plan.generation)
2334
2335
  return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
2335
2336
  if (plan.activeIds.length === 0) return { ok: true }
2336
2337
  const subtreeSet = new Set(plan.subtreeIds)
2337
2338
  const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
2338
- const fence = { dir, generation: plan.generation }
2339
+ const fence = { dir, endpoint, generation: plan.generation }
2339
2340
 
2340
2341
  const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
2341
2342
  const restored = await codexRestoreColdPlan(plan, dir)
@@ -2343,9 +2344,9 @@ export const codexHarness: Harness = {
2343
2344
  }
2344
2345
 
2345
2346
  const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
2346
- const after = await codexColdPreflight(threadId, dir, plan.generation)
2347
+ const after = await codexColdPreflight(threadId, dir, plan.generation, endpoint)
2347
2348
  if (!after.ok) return after
2348
- if (codexRuntimeGeneration(dir) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
2349
+ if (codexRuntimeGeneration(dir, endpoint) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
2349
2350
  if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
2350
2351
  return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
2351
2352
  if (after.receipt.activeIds.length)
@@ -2378,7 +2379,9 @@ export const codexHarness: Harness = {
2378
2379
  return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
2379
2380
  return codexRestoreColdPlan(suppliedReceipt)
2380
2381
  }
2381
- const sock = codexAppServerSock(runtimeRoot())
2382
+ const endpoint = codexEndpointForRecord(rec)
2383
+ if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
2384
+ const sock = endpoint.socketPath
2382
2385
  const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
2383
2386
  const [active, archived] = await Promise.all([
2384
2387
  codexThreadList(sock, { archived: false, sourceKinds: [] }),
@@ -2395,28 +2398,7 @@ export const codexHarness: Harness = {
2395
2398
  if (!restored.ok) return reconcile()
2396
2399
  return reconcile()
2397
2400
  },
2398
- sharedRuntimes: (runtimeDir) => [{
2399
- key: 'codex-app-server',
2400
- label: 'Codex app-server',
2401
- pidFile: codexAppServerPid(runtimeDir),
2402
- receiptFile: codexAppServerReceipt(runtimeDir),
2403
- residency: async () => {
2404
- const sock = codexAppServerSock(runtimeDir)
2405
- let pid = 0
2406
- try { pid = Number(readFileSync(codexAppServerPid(runtimeDir), 'utf8').trim()) } catch { /* stale/missing pid */ }
2407
- const pidLive = pid > 0 && !!processStartToken(pid)
2408
- const listener = await listenerAt(sock, 800)
2409
- if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
2410
- if (pidLive && (!codexRuntimeGeneration(runtimeDir) || listener !== 'live'))
2411
- return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
2412
- if (!pidLive || listener !== 'live')
2413
- return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
2414
- const result = await codexLoadedReferenceIds(sock)
2415
- return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
2416
- },
2417
- mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts),
2418
- probe: () => codexSharedRuntimeProbe(runtimeDir),
2419
- }],
2401
+ sharedRuntimes: codexRuntimeDescriptors,
2420
2402
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
2421
2403
  // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
2422
2404
  // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
@@ -2485,10 +2467,12 @@ const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string,
2485
2467
  async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyRecord | null): Promise<CodexHeadlessLaunchReadinessProof | null> {
2486
2468
  const record = current()
2487
2469
  if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId) return null
2470
+ const endpoint = codexEndpointForRecord(record, record.runtimeDir)
2471
+ if (!endpoint) return null
2488
2472
  const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
2489
- .find((candidate) => candidate.key === 'codex-app-server')
2473
+ .find((candidate) => candidate.key === codexDescriptorKey(endpoint))
2490
2474
  if (!descriptor?.residency) return null
2491
- const generationBefore = codexRuntimeGenerationProof(record.runtimeDir)
2475
+ const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint)
2492
2476
  if (!generationBefore) return null
2493
2477
  let resident: Awaited<ReturnType<NonNullable<SharedRuntimeDescriptor['residency']>>>
2494
2478
  try { resident = await descriptor.residency() }
@@ -2497,7 +2481,7 @@ async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyReco
2497
2481
  if (!resident.referenceIds.includes(record.harnessSessionId)) return null
2498
2482
  const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId)
2499
2483
  if (!owners || owners.length !== 1 || owners[0] !== record.session) return null
2500
- const generationAfter = codexRuntimeGenerationProof(record.runtimeDir)
2484
+ const generationAfter = codexRuntimeGenerationProof(record.runtimeDir, endpoint)
2501
2485
  if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter)) return null
2502
2486
  return Object.freeze({
2503
2487
  kind: 'codex-headless-shared-runtime',
@@ -2555,7 +2539,7 @@ export const codexHeadlessHarness: Harness = {
2555
2539
  // extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
2556
2540
  // dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
2557
2541
  // CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
2558
- // deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
2542
+ // deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
2559
2543
  // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
2560
2544
  // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
2561
2545
  // one-run defence. See pi-harness.ts for the extension source + trust mechanics.
@@ -2589,7 +2573,7 @@ export const piHarness: Harness = {
2589
2573
  // socket the generated extension binds. socketLive is already probed for every windowed session.
2590
2574
  liveness: socketListenerLiveness,
2591
2575
  leafOwnerNeedle: (rec) => rec.session,
2592
- deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
2576
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2593
2577
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2594
2578
  coldRuntime: async () => ({ ok: true }),
2595
2579
  // reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
@@ -2622,8 +2606,7 @@ export const opencodeHarness: Harness = {
2622
2606
  headless: false,
2623
2607
  events: OPENCODE_EVENTS,
2624
2608
  // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
2625
- // socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
2626
- // parse-confirmed write) and socket-listener liveness are reused verbatim — no opencode transport code.
2609
+ // socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
2627
2610
  ownsRendezvous: true,
2628
2611
  paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
2629
2612
  launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
@@ -2655,7 +2638,7 @@ export const opencodeHarness: Harness = {
2655
2638
  // load still reads honestly from the process signal instead of a false offline.
2656
2639
  liveness: socketListenerOrPidAliveLiveness,
2657
2640
  leafOwnerNeedle: (rec) => rec.session,
2658
- deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
2641
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2659
2642
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2660
2643
  coldRuntime: async () => ({ ok: true }),
2661
2644
  // owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
@@ -2681,6 +2664,7 @@ export const opencodeHeadlessHarness: Harness = {
2681
2664
  return deliverViaSocketOrWake(
2682
2665
  rec.session,
2683
2666
  text,
2667
+ rec.mid,
2684
2668
  () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)),
2685
2669
  `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
2686
2670
  )