spexcode 0.5.6 → 0.5.7

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 (32) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +18 -1
  3. package/spec-cli/src/client.ts +21 -0
  4. package/spec-cli/src/git.ts +380 -198
  5. package/spec-cli/src/harness.ts +116 -24
  6. package/spec-cli/src/help.ts +3 -1
  7. package/spec-cli/src/index.ts +41 -7
  8. package/spec-cli/src/layout.ts +2 -0
  9. package/spec-cli/src/mentions.ts +10 -5
  10. package/spec-cli/src/process-identity.ts +20 -0
  11. package/spec-cli/src/session-maintenance.ts +2 -1
  12. package/spec-cli/src/sessions.ts +898 -140
  13. package/spec-cli/src/specs.ts +3 -3
  14. package/spec-dashboard/dist/assets/{App-B72LuS5I.js → App-u2P7KdSg.js} +2 -2
  15. package/spec-dashboard/dist/assets/{Dashboard-C5X4Va3V.js → Dashboard-B8wp5_61.js} +3 -3
  16. package/spec-dashboard/dist/assets/{EvalsPage-BTvJIW8Q.js → EvalsPage-Bq1Tkb8y.js} +1 -1
  17. package/spec-dashboard/dist/assets/{IssuesPage-Bn94h_HQ.js → IssuesPage-BlkPSkmv.js} +1 -1
  18. package/spec-dashboard/dist/assets/{MobileApp-ClbtwZ1e.js → MobileApp-B1GxRZXK.js} +2 -2
  19. package/spec-dashboard/dist/assets/{Modal-6l_QtCKF.js → Modal-bAkq9IIT.js} +1 -1
  20. package/spec-dashboard/dist/assets/{PageScroll-B2kxcqJJ.js → PageScroll-px_rUZVJ.js} +1 -1
  21. package/spec-dashboard/dist/assets/{ProjectsPage-C8IPsMKV.js → ProjectsPage-8uGqYM12.js} +1 -1
  22. package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
  23. package/spec-dashboard/dist/assets/{SessionWindow-Dag_GiJB.js → SessionWindow-IspcLjFA.js} +1 -1
  24. package/spec-dashboard/dist/assets/{Settings-J3aibcXo.js → Settings-bpAbfnmS.js} +1 -1
  25. package/spec-dashboard/dist/assets/{Thread-Dg35J-Pu.js → Thread-BpL3N3kw.js} +11 -11
  26. package/spec-dashboard/dist/assets/{TimelineChat-f0UF9fXq.js → TimelineChat-Ckmb1Ez2.js} +1 -1
  27. package/spec-dashboard/dist/assets/{data-SNi0AmVT.js → data-CQFbQEMH.js} +1 -1
  28. package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
  29. package/spec-dashboard/dist/assets/{index-BUKLPN_4.js → index-Di1ch5dd.js} +5 -5
  30. package/spec-dashboard/dist/index.html +2 -2
  31. package/spec-dashboard/dist/assets/SessionInterface-B5jf7dW7.js +0 -39
  32. package/spec-dashboard/dist/assets/index-CzutlTDf.css +0 -1
@@ -16,7 +16,7 @@ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from '.
16
16
  import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
17
17
  import { git } from './git.js'
18
18
  import { shQuote } from './sh.js'
19
- import { detachedRuntimeGenerationToken, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
19
+ import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
20
20
 
21
21
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
22
22
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -86,6 +86,13 @@ export type HarnessColdPreflight =
86
86
  | { ok: true; alreadyCold?: boolean; receipt?: unknown }
87
87
  | { ok: false; reason: string }
88
88
 
89
+ // The corrupt-record quarantine path has no typed session record to pass into cold storage. The adapter therefore
90
+ // owns this separate proof: it can archive one exact native orphan, return only public audit facts, and retain an
91
+ // in-memory compensation closure for the caller's atomic record move. Product code never sees a native receipt.
92
+ export type HarnessOrphanThreadQuarantine =
93
+ | { ok: true; audit: { adapter: string; threadId: string; action: 'archived' | 'already-unloaded' }; compensate(): Promise<{ ok: true } | { ok: false; reason: string }> }
94
+ | { ok: false; reason: string }
95
+
89
96
  export type AdapterLoadedReferenceState = {
90
97
  healthy: boolean
91
98
  loaded: boolean
@@ -298,6 +305,9 @@ export interface Harness {
298
305
  // reference or return a loud reason; adapters without such a resident reference return {ok:true}.
299
306
  coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
300
307
  restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
308
+ // Recovery for an unreadable governed record. This accepts no record-shaped ownership claim: the adapter must
309
+ // prove the native target has zero other governed owners, is idle and descendant-free, then archive only it.
310
+ quarantineOrphanThread?(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine>
301
311
  // Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
302
312
  // references and protect a sibling-owned control plane without learning harness command names.
303
313
  sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
@@ -328,19 +338,26 @@ export interface Harness {
328
338
  resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
329
339
  }
330
340
 
331
- // a prompt-dispatch outcome. ok=true means delivery is confirmed at the layer that harness proves it: claude at
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
332
346
  // the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
333
347
  // still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
334
348
  // accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
335
349
  // (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
336
350
  // re-exports it for its existing importers.
337
- export type DispatchResult = { ok: boolean; error?: string }
351
+ export type DispatchResult = { ok: boolean; outcome?: DispatchOutcome; error?: string }
338
352
  export type HarnessDeliveryRecord = {
339
353
  session: string
340
354
  worktreePath?: string
341
355
  harnessSessionId?: string | null
342
356
  runtimeDir?: string
343
357
  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
344
361
  }
345
362
  // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
346
363
  // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
@@ -440,6 +457,7 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
440
457
  }
441
458
  export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
442
459
  export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
460
+ const codexAppServerLegacyScope = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope')
443
461
  type CodexRuntimeGenerationProof = Readonly<{
444
462
  identity: VerifiedDetachedRuntime
445
463
  socket: Readonly<{ path: string; dev: number; ino: number }>
@@ -464,6 +482,18 @@ function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
464
482
  return proof ? codexRuntimeGenerationToken(proof) : null
465
483
  }
466
484
 
485
+ function codexMutationGeneration(dir = runtimeRoot()): string | null {
486
+ const current = codexRuntimeGeneration(dir)
487
+ if (current) return current
488
+ let pid: number
489
+ try {
490
+ pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
491
+ if (!Number.isInteger(pid) || pid <= 0 || !statSync(codexAppServerSock(dir)).isSocket()) return null
492
+ } catch { return null }
493
+ if (!migrateLegacyDetachedRuntimeReceipt(pid, codexAppServerLegacyScope(dir), codexAppServerReceipt(dir))) return null
494
+ return codexRuntimeGeneration(dir)
495
+ }
496
+
467
497
  // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
468
498
  // the launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
469
499
  // exec's the visible TUI. The launcher, never a raw `tsx cli.ts` pair: it owns tsx resolution and the
@@ -595,10 +625,11 @@ export function codexHandshakeMessages(threadId: string): JsonRpc[] {
595
625
  // only sent with a turnId read live from the thread, never from SpexCode's session status. When the thread is
596
626
  // idle (no active turn id), START a fresh turn (turn/start). `id` is parameterized so a steer that loses the
597
627
  // expectedTurnId race (turn ended in the read→steer window) can retry as a turn/start with id 5.
598
- export function codexInjectMessage(threadId: string, text: string, cwd: string | undefined, activeTurnId: string | null, id = 4): JsonRpc {
628
+ export function codexInjectMessage(threadId: string, text: string, cwd: string | undefined, activeTurnId: string | null, id = 4, clientUserMessageId?: string): JsonRpc {
629
+ const marker = clientUserMessageId ? { clientUserMessageId } : {}
599
630
  if (activeTurnId)
600
- return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId } }
601
- return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}) } }
631
+ return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId, ...marker } }
632
+ return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}), ...marker } }
602
633
  }
603
634
 
604
635
  // the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. With
@@ -1138,7 +1169,7 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
1138
1169
  }
1139
1170
 
1140
1171
  async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
1141
- const generationBefore = codexRuntimeGeneration(dir)
1172
+ const generationBefore = codexMutationGeneration(dir)
1142
1173
  if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
1143
1174
  const sock = codexAppServerSock(dir)
1144
1175
  const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
@@ -1195,7 +1226,7 @@ const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
1195
1226
  }
1196
1227
 
1197
1228
  async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
1198
- const generation = expectedGeneration ?? codexRuntimeGeneration(dir)
1229
+ const generation = expectedGeneration ?? codexMutationGeneration(dir)
1199
1230
  if (!generation || codexRuntimeGeneration(dir) !== generation)
1200
1231
  return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
1201
1232
  const sock = codexAppServerSock(dir)
@@ -1295,6 +1326,55 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
1295
1326
  return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
1296
1327
  }
1297
1328
 
1329
+ async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine> {
1330
+ const dir = runtimeRoot()
1331
+ const generation = codexMutationGeneration(dir)
1332
+ if (!generation) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1333
+ const owners = governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId)
1334
+ if (owners === null) return { ok: false, reason: 'governed Codex thread-owner census is unreadable' }
1335
+ if (owners.length) return { ok: false, reason: `Codex native thread ${threadId} has governed owner(s) ${owners.join(', ')}` }
1336
+ const before = await codexColdPreflight(threadId, dir, generation)
1337
+ if (!before.ok) return before
1338
+ const plan = before.receipt
1339
+ if (plan.descendantIds.length || plan.guard.descendantIds.length)
1340
+ return { ok: false, reason: `Codex native thread ${threadId} has descendants (${[...new Set([...plan.descendantIds, ...plan.guard.descendantIds])].join(', ')})` }
1341
+ if (plan.guard.targetTurnPresence === 'active' || plan.guard.targetTurnPresence === 'unknown')
1342
+ return { ok: false, reason: `Codex native thread ${threadId} is ${plan.guard.targetTurnPresence === 'active' ? 'active' : 'unknown'}` }
1343
+ if (plan.subtreeIds.length !== 1 || plan.subtreeIds[0] !== threadId)
1344
+ return { ok: false, reason: `Codex native thread ${threadId} has an ambiguous ownership closure` }
1345
+ const unchangedOwners = () => governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId)
1346
+ const rollback = () => codexRestoreColdPlan(plan, dir)
1347
+ if (plan.activeIds.length === 0) {
1348
+ if (plan.archivedIds.length !== 1 || plan.archivedIds[0] !== threadId || plan.guard.referenceIds.includes(threadId))
1349
+ return { ok: false, reason: `Codex native thread ${threadId} is not uniquely archived and unloaded` }
1350
+ const afterOwners = unchangedOwners()
1351
+ if (afterOwners === null || afterOwners.length) return { ok: false, reason: 'governed Codex thread-owner census changed during quarantine verification' }
1352
+ return { ok: true, audit: { adapter: 'codex', threadId, action: 'already-unloaded' }, compensate: async () => ({ ok: true }) }
1353
+ }
1354
+ if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
1355
+ return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
1356
+ const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
1357
+ const archived = await codexThreadMutation(codexAppServerSock(dir), 'thread/archive', threadId, { dir, generation })
1358
+ if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}` }
1359
+ const after = await codexColdPreflight(threadId, dir, generation)
1360
+ const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
1361
+ if (!after.ok) {
1362
+ const restored = await rollback()
1363
+ return failed(restored.ok ? after.reason : `${after.reason}; ${restored.reason}`)
1364
+ }
1365
+ const afterOwners = unchangedOwners()
1366
+ const afterPlan = after.receipt
1367
+ const valid = afterPlan.descendantIds.length === 0 && afterPlan.subtreeIds.length === 1 && afterPlan.subtreeIds[0] === threadId &&
1368
+ afterPlan.activeIds.length === 0 && afterPlan.archivedIds.length === 1 && afterPlan.archivedIds[0] === threadId &&
1369
+ !afterPlan.guard.referenceIds.includes(threadId) && siblingIds.every((id) => afterPlan.guard.referenceIds.includes(id)) &&
1370
+ afterOwners !== null && afterOwners.length === 0
1371
+ if (!valid) {
1372
+ const restored = await rollback()
1373
+ return failed(restored.ok ? `Codex orphan thread ${threadId} changed during archive verification` : `Codex orphan thread ${threadId} changed during archive verification; ${restored.reason}`)
1374
+ }
1375
+ return { ok: true, audit: { adapter: 'codex', threadId, action: 'archived' }, compensate: rollback }
1376
+ }
1377
+
1298
1378
  async function codexMutationGuard(
1299
1379
  threadId: string,
1300
1380
  dir = runtimeRoot(),
@@ -1598,13 +1678,19 @@ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = f
1598
1678
  })
1599
1679
  }
1600
1680
 
1601
- function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cwd?: string): Promise<DispatchResult> {
1681
+ const codexTurnConfirmMs = () => {
1682
+ const configured = Number(process.env.SPEXCODE_CODEX_TURN_CONFIRM_MS)
1683
+ return Number.isFinite(configured) && configured >= 100 ? configured : 15_000
1684
+ }
1685
+
1686
+ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cwd?: string, clientUserMessageId?: string): Promise<DispatchResult> {
1602
1687
  return new Promise((resolve) => {
1603
1688
  const conn: Socket = createConnection(sock)
1604
1689
  const hs = codexHandshakeMessages(threadId) // [initialize(1), initialized, thread/loaded/list(2), thread/read(3)]
1605
1690
  let buf = Buffer.alloc(0), upgraded = false, settled = false
1606
1691
  let fragOp = 0, fragBuf = Buffer.alloc(0)
1607
1692
  let steering = false // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
1693
+ let injected = false
1608
1694
  const done = (r: DispatchResult) => {
1609
1695
  if (settled) return
1610
1696
  settled = true
@@ -1612,9 +1698,10 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1612
1698
  try { conn.destroy() } catch { /* */ }
1613
1699
  resolve(r)
1614
1700
  }
1615
- const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not confirm the turn within 5000ms' }), 5000)
1616
- conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }))
1617
- conn.on('close', () => done({ ok: false, error: 'codex app-server closed the connection before the turn was confirmed' }))
1701
+ const unresolved = (error: string) => done({ ok: false, outcome: injected ? 'commit-unknown' : 'rejected', error })
1702
+ const timer = setTimeout(() => unresolved(`codex app-server did not confirm the turn within ${codexTurnConfirmMs()}ms`), codexTurnConfirmMs())
1703
+ conn.on('error', (e) => unresolved(`codex app-server connection failed: ${rpcError(e)}`))
1704
+ conn.on('close', () => unresolved('codex app-server closed the connection before the turn was confirmed'))
1618
1705
  const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
1619
1706
  conn.on('connect', () => {
1620
1707
  const key = randomBytes(16).toString('base64')
@@ -1625,12 +1712,14 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1625
1712
  try { m = JSON.parse(json) } catch { return }
1626
1713
  if (m.error) {
1627
1714
  if (m.id === 4 && steering) // active turn ended in the read→steer window → just start a fresh turn
1628
- return send(codexInjectMessage(threadId, text, cwd, null, 5))
1715
+ return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId))
1629
1716
  if (m.id === 3) // thread not readable yet (a freshly-started thread is "not materialized
1630
- return send(codexInjectMessage(threadId, text, cwd, null, 5)) // before its first user message") → no in-progress turn possible, so just turn/start
1631
- return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
1717
+ 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)}` })
1632
1719
  }
1633
- if (m.id === 1 && m.result) return send(hs[2]) // initialize ack ask which threads are loaded
1720
+ // JSON-RPC initialization is ordered. Under a quiet server the premature notification happened to win;
1721
+ // under shared app-server load it was ignored and every later turn waited until the old 5s wall expired.
1722
+ if (m.id === 1 && m.result) { send(hs[1]); return send(hs[2]) } // initialize ack → initialized → ask which threads are loaded
1634
1723
  if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
1635
1724
  const loaded = (m.result as { data?: unknown })?.data
1636
1725
  if (Array.isArray(loaded) && !loaded.includes(threadId))
@@ -1640,9 +1729,10 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1640
1729
  if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
1641
1730
  const turnId = activeTurnIdFromThread(m.result)
1642
1731
  steering = !!turnId
1643
- return send(codexInjectMessage(threadId, text, cwd, turnId)) // id 4: turn/steer the live turn, or turn/start
1732
+ injected = true
1733
+ return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)) // id 4: turn/steer the live turn, or turn/start
1644
1734
  }
1645
- if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true }) // steer/start accepted → the model has the message
1735
+ if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true, outcome: 'accepted' }) // steer/start accepted → the model has the message
1646
1736
  }
1647
1737
  const drainFrames = () => {
1648
1738
  for (;;) {
@@ -1656,7 +1746,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1656
1746
  let payload = buf.slice(dataStart, dataStart + len)
1657
1747
  if (masked) { const mk = buf.slice(off, off + 4); const u = Buffer.alloc(len); for (let i = 0; i < len; i++) u[i] = payload[i] ^ mk[i % 4]; payload = u }
1658
1748
  buf = buf.slice(dataStart + len)
1659
- if (op === 0x8) return done({ ok: false, error: 'codex app-server sent a WebSocket close before turn/start was confirmed' })
1749
+ if (op === 0x8) return unresolved('codex app-server sent a WebSocket close before turn/start was confirmed')
1660
1750
  if (op === 0x9) { conn.write(encodeWsFrame(0xa, payload)); continue } // ping → pong
1661
1751
  if (op === 0xa) continue // pong
1662
1752
  if (op === 0x0) fragBuf = Buffer.concat([fragBuf, payload]) // continuation
@@ -1673,7 +1763,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1673
1763
  if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
1674
1764
  upgraded = true
1675
1765
  buf = buf.slice(i + 4)
1676
- send(hs[0]); send(hs[1]) // initialize + the initialized notification; loaded/list → read → inject follow on the acks
1766
+ send(hs[0]) // wait for initialize before its required initialized notification
1677
1767
  }
1678
1768
  drainFrames()
1679
1769
  })
@@ -1683,8 +1773,8 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
1683
1773
  // fire a turn on an owned thread over the per-project socket — the same steer-vs-start delivery the live UI
1684
1774
  // uses. The launcher calls this to materialize a freshly-started thread's rollout (the first turn = the launch
1685
1775
  // prompt), and delivery reuses it for follow-ups. Exported so the CLI's `codex-launch` can fire the first turn.
1686
- export function codexTurn(sock: string, threadId: string, text: string, cwd?: string): Promise<DispatchResult> {
1687
- return sendCodexAppServerTurn(sock, threadId, text, cwd)
1776
+ export function codexTurn(sock: string, threadId: string, text: string, cwd?: string, clientUserMessageId?: string): Promise<DispatchResult> {
1777
+ return sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId)
1688
1778
  }
1689
1779
 
1690
1780
  // @@@ codex rollout on disk - the visible TUI resumes a thread via `codex --remote resume <tid>`, which reads
@@ -1740,7 +1830,7 @@ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string
1740
1830
  if (!r.ok) return { ok: false, error: `${r.error} — prompt NOT delivered` }
1741
1831
  threadId = r.threadId
1742
1832
  }
1743
- return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath)
1833
+ return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.deliveryId)
1744
1834
  }
1745
1835
 
1746
1836
  // idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
@@ -2280,6 +2370,7 @@ export const codexHarness: Harness = {
2280
2370
  if (verified.ok) return verified
2281
2371
  return compensate(verified.reason)
2282
2372
  },
2373
+ quarantineOrphanThread: codexQuarantineOrphanThread,
2283
2374
  restoreRuntime: async (rec, suppliedReceipt) => {
2284
2375
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2285
2376
  if (suppliedReceipt !== undefined) {
@@ -2363,7 +2454,7 @@ const sameCodexHeadlessReadinessProof = (left: CodexHeadlessLaunchReadinessProof
2363
2454
  left.target.referenceState === right.target.referenceState &&
2364
2455
  left.target.protectsControlPlane === right.target.protectsControlPlane
2365
2456
 
2366
- const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string): string[] | null => {
2457
+ const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string, excludingSessionId?: string): string[] | null => {
2367
2458
  const root = join(runtimeDir, 'sessions')
2368
2459
  let entries
2369
2460
  try { entries = readdirSync(root, { withFileTypes: true }) }
@@ -2371,6 +2462,7 @@ const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string,
2371
2462
  const owners: string[] = []
2372
2463
  for (const entry of entries) {
2373
2464
  if (!entry.isDirectory()) continue
2465
+ if (entry.name === excludingSessionId) continue
2374
2466
  let parsed: unknown
2375
2467
  try { parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8')) }
2376
2468
  catch (error) {
@@ -70,6 +70,8 @@ provably cannot land.`, ['selector', 'project-bound']],
70
70
  archive: ['spex session archive <SEL>', 'Cold-archive it: exact leaf/runtime stopped, worktree and conversation kept.', ['selector']],
71
71
  unarchive: ['spex session unarchive <SEL>', 'Deprecated compatibility spelling: same behavior as resume, relaunching the same conversation.', ['selector']],
72
72
  close: ['spex session close <SEL>', 'Retire the session and its worktree.', ['selector', 'project-bound']],
73
+ quarantine: ['spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <id> --worktree <absent-path> --branch <absent-branch> [--restore]',
74
+ 'Move only an unreadable record after the backend proves every named residue absent. Quarantine and --restore both require the original exact id because corrupt rows are outside selectors.', ['project-bound']],
73
75
  maintain: [[
74
76
  'spex session maintain --allow-stop <SEL> [--allow-resume <SEL>[:force]] … -- <command> [args…]',
75
77
  'spex session maintain --status',
@@ -86,7 +88,7 @@ LOCAL-only (fails loud on a remote backend); show --capture and send are non-int
86
88
 
87
89
  const SESSION_HELP_GROUPS = [
88
90
  { title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge'] },
89
- { title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'maintain'] },
91
+ { title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine', 'maintain'] },
90
92
  { title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask'] },
91
93
  { title: 'Human escape hatch', verbs: ['attach'] },
92
94
  ] as const
@@ -1,5 +1,6 @@
1
1
  import { serve } from '@hono/node-server'
2
- import type { Server as HttpServer } from 'node:http'
2
+ import type { Server as HttpServer, ServerResponse as HttpServerResponse } from 'node:http'
3
+ import { randomUUID } from 'node:crypto'
3
4
  import { installConnectionReaper } from './reaper.js'
4
5
  import { Hono } from 'hono'
5
6
  import { cors } from 'hono/cors'
@@ -15,7 +16,7 @@ import { resolveLayout, mainBranch } from './layout.js'
15
16
  import { getBoardJson } from './graphCache.js'
16
17
  import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
17
18
  import { gitA, gitTry, repoRoot } from './git.js'
18
- import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
19
+ import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
19
20
  import { superviseTimeline, readTimeline } from './session-timeline.js'
20
21
  import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
21
22
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
@@ -461,9 +462,28 @@ app.post('/api/sessions/edges/unwatch', async (c) => {
461
462
  return c.json({ ok }, ok ? 200 : 404)
462
463
  })
463
464
  app.post('/api/sessions', async (c) => {
464
- const body = await c.req.json().catch(() => null)
465
- const result = await sessionCreateRequest(body)
466
- return result.status === 201 ? c.json(result.session, 201) : c.json({ error: result.error }, 400)
465
+ const requestKey = c.req.header('idempotency-key') || randomUUID()
466
+ const controller = new AbortController()
467
+ const rawSignal = c.req.raw.signal
468
+ const outgoing = (c.env as { outgoing?: HttpServerResponse }).outgoing
469
+ const cancel = () => {
470
+ if (!outgoing?.writableEnded) controller.abort(new Error('session-create caller disconnected'))
471
+ }
472
+ const cancelFromRequest = () => controller.abort(rawSignal.reason)
473
+ rawSignal.addEventListener('abort', cancelFromRequest, { once: true })
474
+ outgoing?.once('close', cancel)
475
+ try {
476
+ const body = await c.req.json().catch(() => null)
477
+ const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal })
478
+ if (result.status === 201) {
479
+ c.header('Idempotency-Key', requestKey)
480
+ return c.json(result.session, 201)
481
+ }
482
+ return c.json({ error: result.error, ...(result.code ? { code: result.code } : {}), ...(result.phase ? { phase: result.phase } : {}) }, result.status as any)
483
+ } finally {
484
+ rawSignal.removeEventListener('abort', cancelFromRequest)
485
+ outgoing?.off('close', cancel)
486
+ }
467
487
  })
468
488
  // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
469
489
  // dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
@@ -616,8 +636,10 @@ app.post('/api/sessions/:id/input', async (c) => {
616
636
  // `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
617
637
  // edge ([[comms-edge]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
618
638
  // terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
619
- const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined,
620
- body?.replyVia === 'note' ? { replyVia: 'note' } : {})
639
+ const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined, {
640
+ ...(body?.replyVia === 'note' ? { replyVia: 'note' as const } : {}),
641
+ ...(typeof body?.deliveryId === 'string' && body.deliveryId ? { deliveryId: body.deliveryId } : {}),
642
+ })
621
643
  return c.json(r, r.ok ? 200 : 502)
622
644
  }
623
645
  if (body?.kind === 'keys') {
@@ -642,8 +664,20 @@ app.post('/api/sessions/:id/interrupt', async (c) => {
642
664
  app.post('/api/sessions/:id/close', async (c) => {
643
665
  const sessionId = c.req.param('id')
644
666
  const ok = await closeSession(sessionId)
667
+ // The close route owns its write's visible boundary: filesystem watchers can be unavailable, so cache
668
+ // invalidation must happen before the success response rather than leaving the confirming board to patrol.
669
+ if (ok) notifyBoardChanged('sessions')
645
670
  return c.json(ok ? { ok: true } : { ok: false, error: `no close transition was committed for session ${sessionId}` }, ok ? 200 : 404)
646
671
  })
672
+ app.post('/api/sessions/:id/quarantine', async (c) => {
673
+ const body = await c.req.json().catch(() => null)
674
+ const result = await quarantineCorruptRecord(c.req.param('id'), body)
675
+ return c.json({ ok: true, ...result })
676
+ })
677
+ app.post('/api/sessions/:id/quarantine/restore', async (c) => {
678
+ const result = await restoreQuarantinedRecord(c.req.param('id'))
679
+ return c.json({ ok: true, ...result })
680
+ })
647
681
  // archive / legacy unarchive signpost ([[archive]]) — archive proves exact cold/offline ownership before filing;
648
682
  // `{on:false}` enters the same resume transition and recreates the preserved conversation. {ok:false}=no such session.
649
683
  app.post('/api/sessions/:id/archive', async (c) => {
@@ -184,6 +184,8 @@ export type RawRecord = {
184
184
  adapter_recovery?: string // explicit lifecycle recovery required after a partial adapter mutation; absent on old records
185
185
  launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
186
186
  launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
187
+ create_request_id?: string // SHA-256 digest of the create Idempotency-Key; the raw key is never persisted
188
+ create_payload_hash?: string // normalized create payload bound to create_request_id
187
189
  launch_readiness_pending?: '' | RawLaunchReadinessPending
188
190
  }
189
191
 
@@ -2,7 +2,7 @@
2
2
  // node) and `@session` (an ACTOR — a live session, or `@new` for a fresh worker). The same parser resolves
3
3
  // them in ANY input box; the resolve+dispatch live HERE (CLI-first) so the issues page, the composer, and an agent's
4
4
  // own prompt share one implementation. An `@` "just auto-sends a prompt": resolve it against the live board
5
- // sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s newSession — storage and delivery stay
5
+ // sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s bounded create owner — storage and delivery stay
6
6
  // separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
7
7
 
8
8
  // ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
@@ -87,7 +87,7 @@ function mentionPrompt(threadId: string, node: string | null, author: string, te
87
87
  // A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
88
88
  // landed, so the prompt leads with the status and a verify-on-main-first instruction.
89
89
  export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
90
- // Keep inherited scope inside the text the worker receives: newSession derives its node only from the
90
+ // Keep inherited scope inside the text the worker receives: the create transaction derives its node only from the
91
91
  // raw prompt's first [[id]] mention, so issue dispatch gets no private node-binding argument.
92
92
  const on = node ? ` on node [[${node}]]` : ''
93
93
  const settled = status && status !== 'open'
@@ -109,7 +109,7 @@ export async function dispatchMentions(
109
109
  ): Promise<DispatchOutcome[]> {
110
110
  const { actors } = parseMentions(text)
111
111
  if (!actors.length) return []
112
- const { sendText, listSessions, newSession } = await import('./sessions.js')
112
+ const { sendText, listSessions, sessionCreateRequest } = await import('./sessions.js')
113
113
  const sessions = await listSessions()
114
114
  const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
115
115
  const out: DispatchOutcome[] = []
@@ -120,8 +120,13 @@ export async function dispatchMentions(
120
120
  // deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
121
121
  const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
122
122
  try {
123
- const s = await newSession(newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions), r.launcher)
124
- out.push({ token: r.token, result: 'spawned', detail: s.id, ...(settled ? { note: `thread ${settled}` } : {}) })
123
+ const created = await sessionCreateRequest({
124
+ prompt: newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status),
125
+ parent: spawnParent(ctx.author, sessions),
126
+ launcher: r.launcher,
127
+ })
128
+ if (created.status !== 201) throw new Error(`${created.code || 'session_create_failed'}: ${created.error}`)
129
+ out.push({ token: r.token, result: 'spawned', detail: created.session.id, ...(settled ? { note: `thread ${settled}` } : {}) })
125
130
  } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
126
131
  continue
127
132
  }
@@ -142,6 +142,26 @@ export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter:
142
142
  return live
143
143
  }
144
144
 
145
+ // A v3 scope is only a migration witness when every recorded and live Linux identity agrees. Readers never
146
+ // call this: minting a v4 receipt belongs to the write admission path that needs the shared runtime.
147
+ export function migrateLegacyDetachedRuntimeReceipt(
148
+ pid: number,
149
+ legacyScopeFile: string,
150
+ receiptFile: string,
151
+ adapter: ProcessAdapter = hostProcessAdapter,
152
+ ): boolean {
153
+ if (adapter.platform !== 'linux' || verifyDetachedRuntime(pid, receiptFile, adapter).ok) return false
154
+ let fields: string[]
155
+ try { fields = readFileSync(legacyScopeFile, 'utf8').trim().split(/\s+/) }
156
+ catch { return false }
157
+ const observed = observeDetachedRuntime(pid, adapter)
158
+ if (!observed.ok || observed.identity.linuxSessionId === undefined) return false
159
+ const expected = ['detached-v3', String(pid), observed.identity.startToken, String(observed.identity.processGroupId), String(observed.identity.linuxSessionId)]
160
+ if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) return false
161
+ try { writeDetachedRuntimeReceipt(pid, receiptFile, adapter); return true }
162
+ catch { return false }
163
+ }
164
+
145
165
  export function writeDetachedRuntimeReceipt(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): VerifiedDetachedRuntime {
146
166
  const observed = observeDetachedRuntime(pid, adapter)
147
167
  if (!observed.ok) throw new Error(`cannot prove detached shared runtime: ${observed.reason}`)
@@ -29,6 +29,7 @@ export type Operation =
29
29
  | ({ op: 'resume'; sessionId: string; force: boolean } & Partial<{ authorization: Authorization }>)
30
30
  | { op: 'archive'; sessionId: string }
31
31
  | { op: 'close'; sessionId: string }
32
+ | { op: 'quarantine'; sessionId: string }
32
33
  | { op: 'merge-dispatch'; sessionId: string }
33
34
  | { op: 'queue-drain' }
34
35
  | { op: 'attach'; sessionId: string }
@@ -105,7 +106,7 @@ type CoordinatorInput = {
105
106
 
106
107
  const OPERATIONS = new Set<Operation['op']>([
107
108
  'create', 'fallback-create', 'lifecycle-transition', 'hook-state', 'send', 'raw-key-input',
108
- 'terminal-input', 'interrupt', 'rename', 'sort', 'stop', 'resume', 'archive', 'close',
109
+ 'terminal-input', 'interrupt', 'rename', 'sort', 'stop', 'resume', 'archive', 'close', 'quarantine',
109
110
  'merge-dispatch', 'queue-drain', 'attach', 'shared-spawn',
110
111
  ])
111
112
  const MIN_TTL_MS = 5_000