spexcode 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -2,26 +2,113 @@ import { existsSync } from 'node:fs'
2
2
  import { platform } from 'node:os'
3
3
  import { repoRoot } from './git.js'
4
4
  import { resourceBudgets, type ResourceReport } from './host-resources.js'
5
- import { listSessionIds, readPublicRecordEntry } from './layout.js'
5
+ import { envSessionId, listSessionIds, readPublicRecordEntry } from './layout.js'
6
6
  import { cockpitReview, type CockpitReview } from './cockpit.js'
7
- import { apiBase, apiBaseInfo, assertProjectMatch, fromRaw, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
7
+ import { apiBaseInfo, assertProjectMatch, fromRaw, optionArgv, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
8
8
 
9
9
  export class BackendError extends Error {
10
- constructor(message: string, readonly status?: number) {
10
+ constructor(message: string, readonly status?: number, readonly transport?: unknown) {
11
11
  super(message)
12
12
  this.name = 'BackendError' // cli.ts's top-level handler matches on the NAME, so it needs no import of this class
13
13
  }
14
14
  }
15
15
 
16
+ const usageError = (message: string): Error => {
17
+ const error = new Error(message)
18
+ error.name = 'UsageError'
19
+ return error
20
+ }
21
+
22
+ const hasFlag = (name: string): boolean => optionArgv().includes(`--${name}`)
23
+ function flagValue(name: string): string | null {
24
+ const argv = optionArgv()
25
+ const index = argv.indexOf(`--${name}`)
26
+ if (index < 0) return null
27
+ const value = argv[index + 1]
28
+ if (value === undefined || value.startsWith('--')) throw usageError(`--${name} expects a value`)
29
+ return value
30
+ }
31
+
32
+ let gatewayCookie: { base: string; value: string } | null = null
33
+
34
+ function prepareTls(base: string): void {
35
+ if (!hasFlag('insecure')) return
36
+ if (new URL(base).protocol !== 'https:') throw usageError('--insecure requires an https --api endpoint')
37
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
38
+ }
39
+
40
+ function passwordFor(target: Awaited<ReturnType<typeof apiBaseInfo>>): string | null {
41
+ const password = flagValue('password') ?? process.env.SPEXCODE_PASSWORD ?? null
42
+ if (!password) return null
43
+ if (target.source !== 'flag') throw usageError('--password and SPEXCODE_PASSWORD require explicit --api routing')
44
+ return password
45
+ }
46
+
47
+ function withCookie(init: RequestInit | undefined, cookie: string): RequestInit {
48
+ const headers = new Headers(init?.headers)
49
+ headers.set('cookie', cookie)
50
+ return { ...init, headers }
51
+ }
52
+
53
+ async function loginGateway(base: string, password: string): Promise<string> {
54
+ let response: Response
55
+ try {
56
+ response = await fetch(`${base}/login`, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
59
+ body: new URLSearchParams({ password }).toString(),
60
+ redirect: 'manual',
61
+ })
62
+ } catch (error) {
63
+ throw new BackendError(`gateway login could not reach ${base}: ${(error as Error).message}`, undefined, error)
64
+ }
65
+ if (response.status < 200 || response.status >= 400) throw new BackendError(`gateway login rejected credentials at ${base}`, response.status)
66
+ const headers = response.headers as Headers & { getSetCookie?: () => string[] }
67
+ const cookies = headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']
68
+ const cookie = cookies.map((value) => value.split(';', 1)[0]).find(Boolean)
69
+ if (!cookie) throw new BackendError(`gateway login at ${base} did not return an authorization cookie`, response.status)
70
+ return cookie
71
+ }
72
+
16
73
  // the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
17
74
  // is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
18
75
  async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
19
- const base = await apiBase()
20
- try {
21
- return await fetch(`${base}${path}`, init)
22
- } catch (e) {
23
- throw new BackendError(`no backend reachable at ${base} — run \`spex serve\` in the project, or name one with --api <url> (${(e as Error).message})`)
76
+ const target = await apiBaseInfo()
77
+ const base = target.url
78
+ prepareTls(base)
79
+ const request = async (cookie?: string): Promise<Response> => {
80
+ try {
81
+ return await fetch(`${base}${path}`, cookie ? withCookie(init, cookie) : init)
82
+ } catch (error) {
83
+ throw new BackendError(`no backend reachable at ${base} — run \`spex serve\` in the project, or name one with --api <url> (${(error as Error).message})`, undefined, error)
84
+ }
24
85
  }
86
+ const existing = gatewayCookie?.base === base ? gatewayCookie.value : undefined
87
+ const response = await request(existing)
88
+ if (response.status !== 401) return response
89
+ if (existing) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
90
+ const password = passwordFor(target)
91
+ if (!password) throw new BackendError(`authentication required at ${base} — pass --password <pw> or set SPEXCODE_PASSWORD`, 401)
92
+ const cookie = await loginGateway(base, password)
93
+ gatewayCookie = { base, value: cookie }
94
+ const retried = await request(cookie)
95
+ if (retried.status === 401) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
96
+ return retried
97
+ }
98
+
99
+ export function backendConnectionRefused(error: unknown): boolean {
100
+ if (!(error instanceof BackendError)) return false
101
+ let current = error.transport
102
+ let sawRefusal = false
103
+ const seen = new Set<unknown>()
104
+ while (current && typeof current === 'object' && !seen.has(current)) {
105
+ seen.add(current)
106
+ const code = (current as NodeJS.ErrnoException).code
107
+ if (code && code !== 'ECONNREFUSED') return false
108
+ if (code === 'ECONNREFUSED') sawRefusal = true
109
+ current = (current as { cause?: unknown }).cause
110
+ }
111
+ return sawRefusal
25
112
  }
26
113
  // every MUTATING verb is project-bound ([[remote-client]]'s write guard): resolve the backend, compare its
27
114
  // served root to the cwd project, refuse loudly on a same-host mismatch — an explicit --api/--port skips it.
@@ -41,7 +128,7 @@ function cachedStatus(rec: ReturnType<typeof fromRaw>): DisplayStatus {
41
128
  function corruptCachedSession(id: string, reason: string): Session {
42
129
  const label = `${id.slice(0, 8)} (unreadable record)`
43
130
  return {
44
- id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
131
+ id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
45
132
  parent: null, harness: 'unknown', capabilities: { headless: false }, launcher: null,
46
133
  lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
47
134
  note: `session record is unreadable: ${reason}`, archived: false, archiveHazard: null,
@@ -211,10 +298,14 @@ export async function clientEvals(id: string): Promise<EvalsResult> {
211
298
  throw new BackendError(`session eval pages changed while fetching ${id}; retry the command`)
212
299
  }
213
300
 
214
- // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
215
- export async function clientMerge(id: string): Promise<{ dispatched: boolean; reason?: string }> {
301
+ // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH. The caller returns the exact pair it just
302
+ // reviewed and one durable request key; the backend accepts that decision once before ensuring the agent live.
303
+ export async function clientMerge(id: string, options: { expectedBranchHead: string; expectedBaseHead: string; requestKey: string }): Promise<{ dispatched: boolean; replayed?: boolean; expectedBranchHead?: string; expectedBaseHead?: string; reason?: string; code?: string }> {
216
304
  await guarded('merge')
217
- const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, post({}))
305
+ const headers: Record<string, string> = { 'content-type': 'application/json', 'Idempotency-Key': options.requestKey }
306
+ const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, {
307
+ method: 'POST', headers, body: JSON.stringify({ expectedBranchHead: options.expectedBranchHead, expectedBaseHead: options.expectedBaseHead }),
308
+ })
218
309
  return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
219
310
  }
220
311
 
@@ -245,10 +336,11 @@ export async function clientInterrupt(id: string): Promise<DispatchResult> {
245
336
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
246
337
  }
247
338
 
248
- // POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
339
+ // POST /api/sessions/:id/close — terminal worktree removal. A client-side session id is only an unverified claim.
249
340
  export async function clientClose(id: string): Promise<boolean> {
250
341
  await guarded('session close')
251
- const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({}))
342
+ const source = envSessionId()
343
+ const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({ source: source ? { kind: 'unverified-session-claim', id: source } : { kind: 'user' } }))
252
344
  if (!r.ok) throw new BackendError(`backend refused to close ${id}: ${await r.text()}`, r.status)
253
345
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
254
346
  }
@@ -291,6 +383,13 @@ export async function clientRename(id: string, name: string): Promise<boolean> {
291
383
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
292
384
  }
293
385
 
386
+ export async function clientReparent(children: string[], parent: string): Promise<import('./sessions.js').SessionReparentResult> {
387
+ await guarded('session reparent')
388
+ const r = await apiFetch('/api/sessions/reparent', post({ children, parent }))
389
+ if (!r.ok) throw new BackendError(`backend refused to reparent sessions: ${await r.text()}`, r.status)
390
+ return await r.json() as import('./sessions.js').SessionReparentResult
391
+ }
392
+
294
393
  // POST /api/sessions/:id/input {kind:"keys"} — the LAST-RESORT raw nav-key face of send (tmux send-keys,
295
394
  // NEVER the prompt socket): an ordered token batch drives an interactive TUI menu
296
395
  // ([[nav-mode-key-ordering]]). {ok:false} = unknown session, no live pane, or no valid token delivered.
@@ -3,7 +3,7 @@ import { createConnection, type Socket } from 'node:net'
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
- import { processStartToken, verifyDetachedRuntime } from './process-identity.js'
6
+ import { detachedRuntimeIsGone, processStartToken, verifyDetachedRuntime } from './process-identity.js'
7
7
 
8
8
  export type CodexGenerationEndpoint = Readonly<{
9
9
  id: string
@@ -271,6 +271,32 @@ function hasLegacyResidue(root: string): boolean {
271
271
  return [endpoint.pidFile, endpoint.receiptFile, endpoint.socketPath].some(existsSync)
272
272
  }
273
273
 
274
+ // A generation whose recorded process is provably gone — the host restarted, the OOM killer fired, a /tmp sweep
275
+ // took the socket with it. Death is a POSITIVE fact, not the absence of proof: such a root holds no threads, no
276
+ // peers, and no protective references, so nothing it once carried can be lost by retiring it. Everything else
277
+ // that fails `endpointIdentity` is AMBIGUOUS — a live process we can no longer address — and keeps its root.
278
+ function goneGeneration(endpoint: CodexGenerationEndpoint): boolean {
279
+ if (endpointIdentity(endpoint)) return false
280
+ let pid: number
281
+ try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) }
282
+ catch { return false }
283
+ return Number.isInteger(pid) && pid > 0 && detachedRuntimeIsGone(pid, endpoint.receiptFile)
284
+ }
285
+
286
+ // Retire a gone generation inside the caller's ledger lock, returning the published ledger (null when the
287
+ // generation is still there to serve or merely unaddressable). Its bindings deliberately survive: a session's
288
+ // conversation lives in Codex's on-disk rollout, so those rows are stale pointers to re-pin, not lost threads.
289
+ function retireGoneGenerationLocked(root: string, previous: CodexGenerationLedger, id: string): CodexGenerationLedger | null {
290
+ const generation = previous.generations[id]
291
+ if (!generation || generation.state === 'reclaimed' || !goneGeneration(generation.endpoint)) return null
292
+ return writeLedger(root, previous, {
293
+ current: previous.current === id ? null : previous.current,
294
+ pending: previous.pending === id ? null : previous.pending,
295
+ generations: { ...previous.generations, [id]: { state: 'reclaimed', endpoint: generation.endpoint } },
296
+ bindings: previous.bindings,
297
+ })
298
+ }
299
+
274
300
  function bootstrapBindings(root: string, generationId: string): Record<string, CodexGenerationBinding> {
275
301
  const result: Record<string, CodexGenerationBinding> = {}
276
302
  const sessions = join(root, 'sessions')
@@ -305,13 +331,18 @@ function bindingProtectsGeneration(root: string, sessionId: string, binding: Cod
305
331
  function bootstrapLedger(root: string): CodexGenerationLedger {
306
332
  const legacy = legacyCodexGenerationEndpoint(root)
307
333
  if (!hasLegacyResidue(root)) return emptyLedger()
308
- if (!endpointIdentity(legacy)) throw new Error('legacy Codex root is present but its exact detached PID/start/receipt/socket identity is unproven')
334
+ // Residue left by a legacy root that has since died enters the ledger already reclaimed rather than blocking
335
+ // the bootstrap: it has nothing to drain. Its governed bindings are still recorded so those sessions resolve
336
+ // to a stale pointer they can re-pin, instead of losing the thread they own.
337
+ const gone = goneGeneration(legacy)
338
+ if (!gone && !endpointIdentity(legacy))
339
+ throw new Error('legacy Codex root is present but its exact detached PID/start/receipt/socket identity is unproven')
309
340
  return {
310
341
  version: 3,
311
342
  revision: 0,
312
343
  current: null,
313
344
  pending: null,
314
- generations: { [legacy.id]: { state: 'draining', endpoint: legacy } },
345
+ generations: { [legacy.id]: { state: gone ? 'reclaimed' : 'draining', endpoint: legacy } },
315
346
  bindings: bootstrapBindings(root, legacy.id),
316
347
  }
317
348
  }
@@ -373,9 +404,13 @@ export async function ensureCodexCurrentGeneration(root: string, start: (endpoin
373
404
  }
374
405
  if (previous.current) {
375
406
  const generation = previous.generations[previous.current]
376
- if (!generation || generation.state !== 'current' || !endpointIdentity(generation.endpoint))
377
- throw new Error('canonical Codex generation is missing, replaced, or unproven; refusing to route new traffic')
378
- return { kind: 'current', endpoint: generation.endpoint }
407
+ if (generation && generation.state === 'current' && endpointIdentity(generation.endpoint))
408
+ return { kind: 'current', endpoint: generation.endpoint }
409
+ // A canonical root that is provably gone is retired here and replaced by the rest of this same call:
410
+ // refusing forever would make one host restart permanently un-routable. Anything else stays a refusal.
411
+ const healed = retireGoneGenerationLocked(root, previous, previous.current)
412
+ if (!healed) throw new Error('canonical Codex generation is missing, replaced, or unproven; refusing to route new traffic')
413
+ previous = healed
379
414
  }
380
415
  if (previous.pending) {
381
416
  const pending = previous.generations[previous.pending]
@@ -491,12 +526,67 @@ export function prepareCodexGenerationClose(root: string, sessionId: string, thr
491
526
  if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact generation binding to close`)
492
527
  if (binding.phase === 'record-removing') return
493
528
  const generation = previous.generations[binding.generationId]
494
- if (!generation || generation.state === 'reclaimed') throw new Error(`Codex session ${sessionId} binding names an absent or reclaimed generation`)
529
+ // A retired root can neither route this thread nor be pinned by it, so there is nothing for the removal
530
+ // marker to protect: drop the binding outright and let close proceed. An ABSENT generation is corruption.
531
+ if (!generation) throw new Error(`Codex session ${sessionId} binding names an absent generation`)
532
+ if (generation.state === 'reclaimed') {
533
+ const remaining = { ...previous.bindings }
534
+ delete remaining[sessionId]
535
+ writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings: remaining })
536
+ return
537
+ }
495
538
  const bindings = { ...previous.bindings, [sessionId]: { ...binding, phase: 'record-removing' as const } }
496
539
  writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
497
540
  })
498
541
  }
499
542
 
543
+ // Re-pin an existing exact binding onto another live generation. The transaction phase is carried over, so a
544
+ // registration or close that is mid-flight keeps its crash boundary while its route is corrected.
545
+ export function repinCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string): void {
546
+ withLedgerLockSync(root, () => {
547
+ const previous = readCodexGenerationLedger(root)
548
+ const binding = previous.bindings[sessionId]
549
+ if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact binding to re-pin`)
550
+ const generation = previous.generations[generationId]
551
+ if (!generation || generation.state === 'reclaimed') throw new Error(`Codex generation ${generationId} is absent or reclaimed`)
552
+ if (binding.generationId === generationId) return
553
+ const bindings = { ...previous.bindings, [sessionId]: { ...binding, generationId } }
554
+ writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
555
+ })
556
+ }
557
+
558
+ // The resume boundary. A binding to a LIVE generation still routes only there — an existing conversation is
559
+ // never moved out from under the client that holds it. But a binding whose root is gone names a process, not a
560
+ // conversation: the thread's durable home is its on-disk rollout, which any generation can load. So the gone
561
+ // root is retired and the session is re-pinned to the canonical one, which loads that same rollout. Missing,
562
+ // mismatched, ambiguous, or unprotected bindings remain refusals — resume never invents a route.
563
+ export async function resolveCodexGenerationForResume(
564
+ root: string,
565
+ sessionId: string,
566
+ threadId: string,
567
+ start: (endpoint: CodexGenerationEndpoint) => Promise<void>,
568
+ ): Promise<CodexGenerationEndpoint | null> {
569
+ const ledger = readCodexGenerationLedger(root)
570
+ const binding = ledger.bindings[sessionId]
571
+ if (!binding || binding.threadId !== threadId || !bindingProtectsGeneration(root, sessionId, binding)) return null
572
+ const generation = ledger.generations[binding.generationId]
573
+ if (!generation) return null
574
+ if (generation.state !== 'reclaimed') {
575
+ if (endpointIdentity(generation.endpoint)) return generation.endpoint
576
+ const retired = await withLedgerLock(root, async () => {
577
+ const previous = readCodexGenerationLedger(root)
578
+ if (previous.bindings[sessionId]?.generationId !== binding.generationId) return false
579
+ const observed = previous.generations[binding.generationId]
580
+ if (!observed) return false
581
+ return observed.state === 'reclaimed' || !!retireGoneGenerationLocked(root, previous, binding.generationId)
582
+ })
583
+ if (!retired) return null
584
+ }
585
+ const current = await ensureCodexCurrentGeneration(root, start)
586
+ repinCodexGeneration(root, sessionId, threadId, current.id)
587
+ return current
588
+ }
589
+
500
590
  export function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null {
501
591
  const ledger = readCodexGenerationLedger(root)
502
592
  const binding = ledger.bindings[sessionId]
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'
2
2
  import { relative } from 'node:path'
3
3
  import { materialize, stripSpexcodeBlock, GENERATED_MARK } from './materialize.js'
4
4
  import { HARNESSES } from './harness.js'
5
+ import { gitBinary } from './git.js'
5
6
 
6
7
  // GIT ENV, deliberately INVERTED from git.ts's git(): every call here PRESERVES the hook's environment —
7
8
  // GIT_INDEX_FILE must be honored so the surgery reads/writes the EXACT index this commit is being built
@@ -9,7 +10,7 @@ import { HARNESSES } from './harness.js'
9
10
  // operating on the real one would silently miss them). git.ts strips that env for repo DISCOVERY reasons;
10
11
  // index surgery is the one place the env is the point.
11
12
  const raw = (args: string[], input?: string): string =>
12
- execFileSync('git', args, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
13
+ execFileSync(gitBinary(process.env), args, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
13
14
 
14
15
  function inHead(p: string): boolean {
15
16
  try { raw(['cat-file', '-e', `HEAD:${p}`]); return true } catch { return false }
@@ -1,9 +1,10 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, statSync } from 'node:fs'
2
2
  import { join, relative } from 'node:path'
3
3
  import { execFileSync } from 'node:child_process'
4
- import { git } from './git.js'
4
+ import { git, gitBinary } from './git.js'
5
5
  import { writeManagedBlock, removeManagedBlock } from './harness.js'
6
6
  import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
7
+ import { writeFileIfChanged } from './file-write.js'
7
8
 
8
9
  // the three field-sharpened edges this module owes ([[content-filter]]):
9
10
  // ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
@@ -20,20 +21,13 @@ function commonDirOf(proj: string): string {
20
21
  }
21
22
  const filterDir = (common: string) => join(common, 'spexcode')
22
23
  const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
23
- const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
24
24
  const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
25
25
  const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
26
26
  const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
27
27
  const attributesPath = (common: string) => join(common, 'info', 'attributes')
28
28
 
29
- const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
30
- .filter((row) => row.startsWith('worktree ')).map((row) =>
31
- join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
32
- const legacyFilterTree = (proj: string) => registeredSlots(proj)
33
- .some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
34
-
35
29
  export type ContractFilterPayload = { file: string; content: string }
36
- export type ContractFilterBinding = { file: string; start: string; end: string; legacy?: boolean }
30
+ export type ContractFilterBinding = { file: string; start: string; end: string }
37
31
 
38
32
  // The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
39
33
  // resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
@@ -48,19 +42,15 @@ set -u
48
42
  mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
49
43
  path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
50
44
  here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
51
- binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3 "\t" $4; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
45
+ binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
52
46
  [ -n "$binding" ] || { cat; exit 0; }
53
47
  start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
54
- end="\${rest%%$'\t'*}"; legacy="\${rest#*$'\t'}"
48
+ end="$rest"
55
49
  top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
56
50
  root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
57
51
  key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
58
52
  manifest="$root/trees/$key/contract-filter/manifest"
59
53
  payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
60
- # A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
61
- # per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
62
- marker="$root/trees/$key/contract-filter-v2"
63
- if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
64
54
  strip() {
65
55
  awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
66
56
  { lines[n++] = $0 }
@@ -105,33 +95,54 @@ const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
105
95
 
106
96
  // plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
107
97
  // pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
108
- // binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
109
- // block body (guide + surface:system). settleIndexStat skips untracked entries (no index blob) by design.
110
- export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]): void {
98
+ // binding each file to the filter. `contract` is the assembled block body (guide + surface:system).
99
+ // settleIndexStat skips untracked entries (no index blob) by design and only runs after a real filter change.
100
+ export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[], changedFiles: readonly string[] = []): boolean {
111
101
  const common = commonDirOf(proj)
112
102
  mkdirSync(filterDir(common), { recursive: true })
113
- writeFileSync(shimPath(common), SHIM)
114
- chmodSync(shimPath(common), 0o755)
115
- git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
116
- git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
117
- writeFileSync(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}\t${b.legacy ? 1 : 0}`).join('\n') + '\n')
103
+ let changed = writeFileIfChanged(shimPath(common), SHIM)
104
+ if (changed || (statSync(shimPath(common)).mode & 0o777) !== 0o755) chmodSync(shimPath(common), 0o755)
105
+ const commands = new Map([
106
+ ['filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')],
107
+ ['filter.spexcode.clean', filterCmd(shimPath(common), 'clean')],
108
+ ])
109
+ const configured = new Map<string, string>()
110
+ try {
111
+ for (const row of git(['-C', proj, 'config', '--get-regexp', '^filter\\.spexcode\\.(smudge|clean)$']).trimEnd().split('\n')) {
112
+ const at = row.indexOf(' ')
113
+ if (at > 0) configured.set(row.slice(0, at), row.slice(at + 1))
114
+ }
115
+ } catch {}
116
+ for (const [key, command] of commands) {
117
+ if (configured.get(key) === command) continue
118
+ git(['-C', proj, 'config', key, command])
119
+ changed = true
120
+ }
121
+ changed = writeFileIfChanged(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}`).join('\n') + '\n') || changed
118
122
  const dir = treeFilterDir(proj)
119
- rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true })
123
+ mkdirSync(dir, { recursive: true })
120
124
  const manifest: string[] = []
125
+ const wanted = new Set(['manifest'])
121
126
  for (const [i, payload] of payloads.entries()) {
122
127
  const target = join(dir, String(i))
123
- writeFileSync(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`)
128
+ wanted.add(String(i))
129
+ changed = writeFileIfChanged(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`) || changed
124
130
  manifest.push(`${payload.file}\t${target}`)
125
131
  }
132
+ for (const entry of readdirSync(dir)) {
133
+ if (!wanted.has(entry)) { rmSync(join(dir, entry), { recursive: true, force: true }); changed = true }
134
+ }
126
135
  const manifestPath = join(dir, 'manifest')
127
- writeFileSync(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : ''))
128
- writeFileSync(rootPath(common), `${runtimeRoot(proj)}\n`)
136
+ changed = writeFileIfChanged(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : '')) || changed
137
+ changed = writeFileIfChanged(rootPath(common), `${runtimeRoot(proj)}\n`) || changed
129
138
  // Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
130
139
  // the driver selects a payload from the invoking checkout's tree slot.
131
140
  const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
132
141
  mkdirSync(join(common, 'info'), { recursive: true })
133
- writeManagedBlock(attributesPath(common), entries, ['# ', ''])
134
- settleIndexStat(proj, payloads.map((p) => join(proj, p.file)))
142
+ changed = writeManagedBlock(attributesPath(common), entries, ['# ', '']) || changed
143
+ const settle = changed ? payloads.map((p) => join(proj, p.file)) : changedFiles
144
+ if (settle.length) settleIndexStat(proj, [...new Set(settle)])
145
+ return changed
135
146
  }
136
147
 
137
148
  // settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
@@ -150,7 +161,7 @@ export function settleIndexStat(proj: string, files: string[]): void {
150
161
  const rel = relative(proj, f)
151
162
  try {
152
163
  const indexBlob = git(['-C', proj, 'rev-parse', `:${rel}`]).trim()
153
- const filtered = execFileSync('git', ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
164
+ const filtered = execFileSync(gitBinary(env), ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
154
165
  { input: readFileSync(f), env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
155
166
  if (indexBlob === filtered) git(['-C', proj, 'add', '--renormalize', '--', rel])
156
167
  } catch { /* best-effort */ }
@@ -160,24 +171,29 @@ export function settleIndexStat(proj: string, files: string[]): void {
160
171
  // the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
161
172
  // config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
162
173
  // (evidence blobs), so only OUR two files go, never the dir.
174
+ export function clearContractFilterPayload(proj: string, files: string[] = []): void {
175
+ try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
176
+ settleIndexStat(proj, files)
177
+ }
178
+
163
179
  export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
164
180
  let common: string
165
181
  try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
166
- try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
167
- settleIndexStat(proj, files)
168
- const anotherPayload = registeredSlots(proj).some((slot) => existsSync(join(slot, 'contract-filter', 'manifest')))
169
- const legacyTree = legacyFilterTree(proj)
170
- if (!legacyTree) rmSync(blockPath(common), { force: true })
171
- if (!final && (anotherPayload || legacyTree)) return
182
+ clearContractFilterPayload(proj, files)
183
+ const anotherPayload = final ? false : (() => {
184
+ const rows = git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
185
+ const root = runtimeRoot(proj)
186
+ return rows
187
+ .filter((row) => row.startsWith('worktree '))
188
+ .map((row) => row.slice('worktree '.length))
189
+ .some((tree) => existsSync(join(root, 'trees', encodeProject(tree), 'contract-filter', 'manifest')))
190
+ })()
191
+ if (!final && anotherPayload) return
172
192
  removeManagedBlock(attributesPath(common), ['# ', ''], true)
173
193
  for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
174
194
  try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
175
195
  }
176
- for (const path of [shimPath(common), blockPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
177
- }
178
-
179
- export function retireLegacyContractBlock(proj: string): void {
180
- if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
196
+ for (const path of [shimPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
181
197
  }
182
198
 
183
199
  // is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
@@ -8,9 +8,16 @@ import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
8
8
  // or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
9
9
  // queue is only ever filled by an enqueue — which is why no backlog migration exists.
10
10
 
11
- export type PendingMessage = { mid: string; text: string; from: string | null }
11
+ export type PendingMessage = {
12
+ mid: string
13
+ text: string
14
+ from: string | null
15
+ dispatch?: { operation: 'merge'; requestDigest: string }
16
+ }
12
17
 
13
18
  const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
19
+ const revokedSenderRoot = (): string => join(runtimeRoot(), '.revoked-senders')
20
+ const revokedSenderPath = (id: string): string => join(revokedSenderRoot(), id)
14
21
 
15
22
  // @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
16
23
  // is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
@@ -53,7 +60,10 @@ function read(id: string): PendingMessage[] {
53
60
  return raw.filter((m): m is PendingMessage =>
54
61
  !!m && typeof m === 'object'
55
62
  && typeof (m as PendingMessage).mid === 'string'
56
- && typeof (m as PendingMessage).text === 'string')
63
+ && typeof (m as PendingMessage).text === 'string'
64
+ && ((m as PendingMessage).dispatch === undefined
65
+ || ((m as PendingMessage).dispatch?.operation === 'merge'
66
+ && typeof (m as PendingMessage).dispatch?.requestDigest === 'string')))
57
67
  } catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
58
68
  }
59
69
 
@@ -68,17 +78,59 @@ function write(id: string, msgs: PendingMessage[]): void {
68
78
  renameSync(tmp, path)
69
79
  }
70
80
 
81
+ // A closed sender may have left debt in many other sessions' queues. The marker is deliberately outside its
82
+ // own store (which close removes) and is checked under the sender's record lock by dispatch: a close cannot
83
+ // return while an old process can still append, and a later sweep cannot hand over what it sees here.
84
+ export function revokeSenderDelivery(id: string): void {
85
+ mkdirSync(revokedSenderRoot(), { recursive: true })
86
+ writeFileSync(revokedSenderPath(id), `${id}\n`)
87
+ }
88
+
89
+ export const senderDeliveryRevoked = (id: string): boolean => existsSync(revokedSenderPath(id))
90
+
91
+ export function pendingSnapshot(id: string): PendingMessage[] { return read(id) }
92
+
93
+ // These two writes require the target's delivery lock. They are the queue half of a larger transaction
94
+ // (currently reparent), which must be able to restore the exact previous debt if a later record write fails.
95
+ export function replacePendingWhileLocked(id: string, msgs: PendingMessage[]): void { write(id, msgs) }
96
+ export function revokePendingFromWhileLocked(id: string, sender: string): number {
97
+ const current = read(id)
98
+ const next = current.filter((msg) => msg.from !== sender)
99
+ write(id, next)
100
+ return current.length - next.length
101
+ }
102
+
71
103
  // The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
72
- // both, and the record is written first, so a crash between them leaves a message visible but undelivered —
73
- // never delivered but unrecorded.
104
+ // both, and the record is written first, so delivery is never unrecorded. A keyed dispatch also carries its
105
+ // exact delivery bytes in the timeline receipt, letting its retry restore a queue write lost to a crash.
74
106
  export function enqueue(id: string, msg: PendingMessage): void {
75
107
  write(id, [...read(id), msg])
76
108
  }
77
109
 
110
+ // Caller holds this target's delivery lock. A retry may reach this after either side of the receipt->queue
111
+ // crash boundary; exact mid identity makes reconstruction idempotent without inspecting message text.
112
+ export function ensurePendingWhileLocked(id: string, msg: PendingMessage): boolean {
113
+ const current = read(id)
114
+ if (current.some((pending) => pending.mid === msg.mid)) return false
115
+ write(id, [...current, msg])
116
+ return true
117
+ }
118
+
78
119
  export const pendingMessages = (id: string): PendingMessage[] => read(id)
79
120
 
80
121
  export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
81
122
 
123
+ // Record transitions and queue mutations take locks in the same direction: record locks first, then these
124
+ // target queue locks. A batch reparent needs all child queues held at once so its pointer/watch/debt change
125
+ // either commits together or restores together.
126
+ export async function withDeliveryLocks<T>(rawIds: string[], body: () => Promise<T>, index = 0, ids = [...new Set(rawIds)].sort()): Promise<T> {
127
+ if (index >= ids.length) return body()
128
+ const release = await acquire(ids[index], 30_000)
129
+ if (!release) throw new Error(`delivery queue ${ids[index]}: timed out waiting for transaction lock`)
130
+ try { return await withDeliveryLocks(ids, body, index + 1, ids) }
131
+ finally { release() }
132
+ }
133
+
82
134
  // Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
83
135
  // then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
84
136
  // still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
@@ -95,6 +147,12 @@ export async function drain(
95
147
  for (;;) {
96
148
  const queued = read(id)
97
149
  if (!queued.length) return { delivered, remaining: 0 }
150
+ if (queued[0].from && senderDeliveryRevoked(queued[0].from)) {
151
+ // Closing a sender voids its undelivered output, not the recipient's immutable conversation history.
152
+ // Drop a revoked head and continue so it cannot permanently block the messages behind it.
153
+ write(id, read(id).filter((m) => m.mid !== queued[0].mid))
154
+ continue
155
+ }
98
156
  let ok = false
99
157
  try { ok = await insert(queued[0]) } catch { ok = false }
100
158
  if (!ok) return { delivered, remaining: queued.length }