spexcode 0.5.6 → 0.5.8

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 (34) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +48 -38
  3. package/spec-cli/src/cli.ts +18 -1
  4. package/spec-cli/src/client.ts +21 -0
  5. package/spec-cli/src/git.ts +380 -198
  6. package/spec-cli/src/harness.ts +116 -24
  7. package/spec-cli/src/help.ts +3 -1
  8. package/spec-cli/src/index.ts +41 -7
  9. package/spec-cli/src/layout.ts +2 -0
  10. package/spec-cli/src/lint.ts +38 -28
  11. package/spec-cli/src/mentions.ts +10 -5
  12. package/spec-cli/src/process-identity.ts +20 -0
  13. package/spec-cli/src/session-maintenance.ts +2 -1
  14. package/spec-cli/src/sessions.ts +898 -140
  15. package/spec-cli/src/specs.ts +3 -3
  16. package/spec-dashboard/dist/assets/{App-B72LuS5I.js → App-u2P7KdSg.js} +2 -2
  17. package/spec-dashboard/dist/assets/{Dashboard-C5X4Va3V.js → Dashboard-B8wp5_61.js} +3 -3
  18. package/spec-dashboard/dist/assets/{EvalsPage-BTvJIW8Q.js → EvalsPage-Bq1Tkb8y.js} +1 -1
  19. package/spec-dashboard/dist/assets/{IssuesPage-Bn94h_HQ.js → IssuesPage-BlkPSkmv.js} +1 -1
  20. package/spec-dashboard/dist/assets/{MobileApp-ClbtwZ1e.js → MobileApp-B1GxRZXK.js} +2 -2
  21. package/spec-dashboard/dist/assets/{Modal-6l_QtCKF.js → Modal-bAkq9IIT.js} +1 -1
  22. package/spec-dashboard/dist/assets/{PageScroll-B2kxcqJJ.js → PageScroll-px_rUZVJ.js} +1 -1
  23. package/spec-dashboard/dist/assets/{ProjectsPage-C8IPsMKV.js → ProjectsPage-8uGqYM12.js} +1 -1
  24. package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
  25. package/spec-dashboard/dist/assets/{SessionWindow-Dag_GiJB.js → SessionWindow-IspcLjFA.js} +1 -1
  26. package/spec-dashboard/dist/assets/{Settings-J3aibcXo.js → Settings-bpAbfnmS.js} +1 -1
  27. package/spec-dashboard/dist/assets/{Thread-Dg35J-Pu.js → Thread-BpL3N3kw.js} +11 -11
  28. package/spec-dashboard/dist/assets/{TimelineChat-f0UF9fXq.js → TimelineChat-Ckmb1Ez2.js} +1 -1
  29. package/spec-dashboard/dist/assets/{data-SNi0AmVT.js → data-CQFbQEMH.js} +1 -1
  30. package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
  31. package/spec-dashboard/dist/assets/{index-BUKLPN_4.js → index-Di1ch5dd.js} +5 -5
  32. package/spec-dashboard/dist/index.html +2 -2
  33. package/spec-dashboard/dist/assets/SessionInterface-B5jf7dW7.js +0 -39
  34. package/spec-dashboard/dist/assets/index-CzutlTDf.css +0 -1
@@ -1,13 +1,14 @@
1
1
  import { execFile, spawn } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
- import { randomUUID } from 'node:crypto'
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import { createRequire } from 'node:module'
4
5
  import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, openSync, closeSync, unlinkSync, writeSync } from 'node:fs'
5
6
  import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { seedWorktreeHostState } from './worktree-sources.js'
8
- import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
9
- import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs.js'
10
- import { adapterLoadedReferenceState, defaultHarness, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type TurnFailure, type FailureSubscription, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
9
+ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, type ReviewDiffFile } from './git.js'
10
+ import { loadConfig, loadSpecs, loadSpecsLite, type ConfigPreset, type SpecLite } from './specs.js'
11
+ import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type TurnFailure, type FailureSubscription, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
11
12
  import { materialize } from './materialize.js'
12
13
  import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
13
14
  import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
@@ -218,7 +219,11 @@ export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-
218
219
 
219
220
  // worktrees + branches are created off MAIN even when the server runs inside a worktree.
220
221
  function mainRoot(): string {
221
- try { return dirname(gitCommonDir()) }
222
+ try {
223
+ const checkout = dirname(gitCommonDir())
224
+ const configured = readConfig(checkout).main?.trim()
225
+ return configured ? resolve(checkout, configured) : checkout
226
+ }
222
227
  catch { return repoRoot() }
223
228
  }
224
229
 
@@ -249,6 +254,8 @@ export type SessRec = {
249
254
  launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
250
255
  launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
251
256
  launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
257
+ createRequestId?: string | null // digest of the public Idempotency-Key; binds retry without storing the bearer
258
+ createPayloadHash?: string | null // exact normalized create payload bound to createRequestId
252
259
  launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
253
260
  }
254
261
  type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
@@ -353,10 +360,24 @@ function acquireRecordLockSync(id: string, timeoutMs = 30_000): () => void {
353
360
  }
354
361
  }
355
362
  }
356
- async function acquireRecordLock(id: string, timeoutMs = 30_000): Promise<() => void> {
363
+ const abortedOperation = (signal: AbortSignal): Error => signal.reason instanceof Error
364
+ ? signal.reason
365
+ : Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' })
366
+ async function recordLockPause(signal?: AbortSignal): Promise<void> {
367
+ if (!signal) { await new Promise((resolve) => setTimeout(resolve, 10)); return }
368
+ if (signal.aborted) throw abortedOperation(signal)
369
+ await new Promise<void>((resolve, reject) => {
370
+ const timer = setTimeout(done, 10)
371
+ const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(abortedOperation(signal)) }
372
+ function done() { signal!.removeEventListener('abort', abort); resolve() }
373
+ signal.addEventListener('abort', abort, { once: true })
374
+ })
375
+ }
376
+ async function acquireRecordLock(id: string, timeoutMs = 30_000, signal?: AbortSignal): Promise<() => void> {
357
377
  mkdirSync(recordLockRoot(), { recursive: true })
358
378
  const path = recordLockPath(id), deadline = Date.now() + timeoutMs
359
379
  for (;;) {
380
+ if (signal?.aborted) throw abortedOperation(signal)
360
381
  try {
361
382
  const fd = openSync(path, 'wx')
362
383
  writeSync(fd, String(process.pid))
@@ -370,12 +391,12 @@ async function acquireRecordLock(id: string, timeoutMs = 30_000): Promise<() =>
370
391
  try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
371
392
  }
372
393
  if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
373
- await new Promise((resolve) => setTimeout(resolve, 10))
394
+ await recordLockPause(signal)
374
395
  }
375
396
  }
376
397
  }
377
- async function withRecordLock<T>(id: string, body: () => Promise<T>): Promise<T> {
378
- const release = await acquireRecordLock(id)
398
+ async function withRecordLock<T>(id: string, body: () => Promise<T>, signal?: AbortSignal): Promise<T> {
399
+ const release = await acquireRecordLock(id, 30_000, signal)
379
400
  try { return await body() } finally { release() }
380
401
  }
381
402
  function withRecordLockSync<T>(id: string, body: () => T): T {
@@ -443,6 +464,8 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
443
464
  launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
444
465
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
445
466
  launchOwner: launchOwner || null,
467
+ createRequestId: raw.create_request_id || null,
468
+ createPayloadHash: raw.create_payload_hash || null,
446
469
  launchReadinessPending: pendingRaw ? {
447
470
  version: 1,
448
471
  startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
@@ -531,6 +554,8 @@ function writeRecord(rec: SessRec): void {
531
554
  launcher: rec.launcher ?? '',
532
555
  launch_cmd: rec.launchCmd ?? '',
533
556
  launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
557
+ create_request_id: rec.createRequestId ?? '',
558
+ create_payload_hash: rec.createPayloadHash ?? '',
534
559
  launch_readiness_pending: rec.launchReadinessPending ? {
535
560
  version: 1,
536
561
  startedAt: rec.launchReadinessPending.startedAt,
@@ -1275,7 +1300,7 @@ export const isBackendUnreachable = (e: unknown): boolean =>
1275
1300
  // take unicode), so a CJK prompt survives as the readable name its author typed instead of being stripped to
1276
1301
  // nothing — transliteration would buy ASCII at the cost of a dependency and a name nobody wrote. NFC pins one
1277
1302
  // canonical byte form across IME/OS variants. Non-empty is guaranteed by the 'session' fallback; uniqueness
1278
- // is the caller's job (newSession suffixes the session short-id).
1303
+ // is the caller's job (the create transaction suffixes the session short-id).
1279
1304
  export const slugify = (s: string | null) =>
1280
1305
  (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
1281
1306
 
@@ -1322,7 +1347,7 @@ export function composeCommandPrompt(raw: string, presets: CommandPreset[], spec
1322
1347
  return free ? `${body}\n\n${free}` : body
1323
1348
  }
1324
1349
 
1325
- // Load only the one live preset named by the raw invocation. Both newSession and sendText call this seam, so
1350
+ // Load only the one live preset named by the raw invocation. Both session creation and sendText call this seam, so
1326
1351
  // launch and an existing session's inbox resolve identical plugin data with identical target semantics.
1327
1352
  export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpec[]): Promise<string> {
1328
1353
  const commandName = raw.match(/^\/(\S+)/)?.[1]
@@ -1597,7 +1622,7 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
1597
1622
  const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
1598
1623
 
1599
1624
  // @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
1600
- // re-entrancy-guarded; safe to call on every slot-freeing event (newSession / close / propose) AND on a
1625
+ // re-entrancy-guarded; safe to call on every slot-freeing event (session creation / close / propose) AND on a
1601
1626
  // periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
1602
1627
  // (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
1603
1628
  // so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
@@ -1636,7 +1661,7 @@ const requestQueueDrain = (): void => {
1636
1661
  }
1637
1662
 
1638
1663
  // @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
1639
- // newSession/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
1664
+ // session creation/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
1640
1665
  // going parked writes its global session.json record from a hook subprocess the server never sees, and a crash just makes a
1641
1666
  // socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
1642
1667
  // and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
@@ -1762,16 +1787,13 @@ export function superviseTurnFailures(intervalMs = 1000): void {
1762
1787
  // cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
1763
1788
  // mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local path
1764
1789
  // (a genuinely remote backend) all fall through to allow, so legit remote drive stays untouched.
1765
- export async function assertProjectMatch(verb: string): Promise<void> {
1766
- const { url, source } = await apiBaseInfo()
1790
+ type BackendSettings = { layout?: { main?: string } }
1791
+ function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
1792
+ const { url, source } = target
1767
1793
  if (source === 'flag') return // explicitly routed — the caller named the target
1768
1794
  let localMain: string
1769
1795
  try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
1770
- let served: string | null = null
1771
- try {
1772
- const r = await fetch(`${url}/api/settings`)
1773
- if (r.ok) served = (await r.json() as { layout?: { main?: string } }).layout?.main ?? null
1774
- } catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
1796
+ const served = settings?.layout?.main ?? null
1775
1797
  if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
1776
1798
  let backendMain: string
1777
1799
  try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
@@ -1784,15 +1806,84 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1784
1806
  throw e
1785
1807
  }
1786
1808
  }
1787
-
1788
- type SessionCreateFn = (prompt: string, parent: string | null, launcher?: string) => Promise<Session>
1809
+ export async function assertProjectMatch(verb: string): Promise<void> {
1810
+ const target = await apiBaseInfo()
1811
+ if (target.source === 'flag') return
1812
+ let settings: BackendSettings | null = null
1813
+ try {
1814
+ const r = await fetch(`${target.url}/api/settings`)
1815
+ if (r.ok) settings = await r.json() as BackendSettings
1816
+ } catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
1817
+ assertProjectSettingsMatch(verb, target, settings)
1818
+ }
1819
+
1820
+ export type SessionCreateFailureCode =
1821
+ | 'session_create_timeout'
1822
+ | 'session_create_cancelled'
1823
+ | 'session_create_failed'
1824
+ | 'session_create_cleanup_failed'
1825
+ | 'session_create_key_reused'
1826
+ type SessionCreateFailureStatus = 400 | 408 | 409 | 500 | 504
1827
+ type SessionCreatePhase = 'request' | 'creation-lock' | 'launcher-resolution' | 'target-resolution' | 'git-worktree' | 'materialize' | 'record-write' | 'launcher-queue' | 'cleanup'
1828
+ export class SessionCreateError extends Error {
1829
+ constructor(
1830
+ readonly code: SessionCreateFailureCode,
1831
+ readonly phase: SessionCreatePhase,
1832
+ message: string,
1833
+ readonly status: SessionCreateFailureStatus,
1834
+ ) {
1835
+ super(message)
1836
+ this.name = 'SessionCreateError'
1837
+ }
1838
+ }
1839
+ type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal }
1840
+ type SessionCreateRequestOptions = {
1841
+ requestKey?: string
1842
+ signal?: AbortSignal
1843
+ timeoutMs?: number
1844
+ operation?: 'create' | 'fallback-create'
1845
+ }
1789
1846
  export type SessionCreateRequestResult =
1790
1847
  | { status: 201; session: Session }
1791
- | { status: 400; error: string }
1848
+ | { status: SessionCreateFailureStatus; error: string; code?: SessionCreateFailureCode; phase?: SessionCreatePhase }
1849
+
1850
+ const DEFAULT_CREATE_TIMEOUT_MS = 30_000
1851
+ export function sessionCreateTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
1852
+ const configured = Number(env.SPEXCODE_SESSION_CREATE_TIMEOUT_MS)
1853
+ return Number.isFinite(configured) ? Math.max(250, Math.min(120_000, Math.floor(configured))) : DEFAULT_CREATE_TIMEOUT_MS
1854
+ }
1855
+ function normalizeCreateKey(raw: string | undefined): string {
1856
+ const key = raw?.trim() || randomUUID()
1857
+ if (key.length > 128 || !/^[\x21-\x7e]+$/.test(key)) {
1858
+ throw new SessionCreateError('session_create_failed', 'request', 'Idempotency-Key must be 1-128 visible ASCII characters', 400)
1859
+ }
1860
+ return key
1861
+ }
1862
+ const digest = (value: string): string => createHash('sha256').update(value).digest('hex')
1863
+ export function sessionIdForCreateKey(key: string): string {
1864
+ const hex = digest(`spexcode-session-create\0${key}`)
1865
+ const uuid = `${hex.slice(0, 12)}4${hex.slice(13, 16)}${((parseInt(hex[16], 16) & 3) | 8).toString(16)}${hex.slice(17)}`
1866
+ return `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20, 32)}`
1867
+ }
1868
+ function traceSessionCreate(id: string, requestDigest: string, phase: SessionCreatePhase, event: 'start' | 'finish' | 'abort' | 'publish', detail?: string): void {
1869
+ console.error(`spex session-create ${JSON.stringify({ ts: new Date().toISOString(), request: requestDigest.slice(0, 12), session: id, phase, event, ...(detail ? { detail } : {}) })}`)
1870
+ }
1871
+ function createAbortError(signal: AbortSignal, phase: SessionCreatePhase): SessionCreateError {
1872
+ const timedOut = signal.reason instanceof SessionCreateError && signal.reason.code === 'session_create_timeout'
1873
+ return new SessionCreateError(
1874
+ timedOut ? 'session_create_timeout' : 'session_create_cancelled',
1875
+ phase,
1876
+ timedOut ? `session creation timed out during ${phase}` : `session creation was cancelled during ${phase}`,
1877
+ timedOut ? 504 : 408,
1878
+ )
1879
+ }
1880
+ function throwIfCreateAborted(signal: AbortSignal, phase: SessionCreatePhase): void {
1881
+ if (signal.aborted) throw createAbortError(signal, phase)
1882
+ }
1792
1883
 
1793
1884
  // The API create boundary accepts one small, closed object shape. Unknown fields fail through this generic
1794
1885
  // contract before any worktree is made; removed or misspelled inputs never disappear into defaults.
1795
- export async function sessionCreateRequest(body: unknown, create: SessionCreateFn = newSession): Promise<SessionCreateRequestResult> {
1886
+ export async function sessionCreateRequest(body: unknown, options: SessionCreateRequestOptions = {}): Promise<SessionCreateRequestResult> {
1796
1887
  if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
1797
1888
  const input = body as Record<string, unknown>
1798
1889
  const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
@@ -1801,14 +1892,42 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
1801
1892
  if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
1802
1893
  const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
1803
1894
  const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
1804
- return runSessionOperation({ op: 'create' }, async () => {
1805
- try {
1806
- return { status: 201, session: await create(prompt, parent, launcher) }
1807
- } catch (e) {
1808
- if (e instanceof SessionMaintenanceError) throw e
1809
- return { status: 400, error: String((e as Error).message || e) }
1810
- }
1811
- })
1895
+ let key: string
1896
+ try { key = normalizeCreateKey(options.requestKey) }
1897
+ catch (error) {
1898
+ const failure = error as SessionCreateError
1899
+ return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
1900
+ }
1901
+ const requestDigest = digest(key)
1902
+ const id = sessionIdForCreateKey(key)
1903
+ const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null }))
1904
+ const controller = new AbortController()
1905
+ const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408))
1906
+ if (options.signal?.aborted) cancel()
1907
+ else options.signal?.addEventListener('abort', cancel, { once: true })
1908
+ const timer = setTimeout(() => controller.abort(new SessionCreateError('session_create_timeout', 'request', 'session creation exceeded its deadline', 504)), options.timeoutMs ?? sessionCreateTimeoutMs())
1909
+ timer.unref?.()
1910
+ traceSessionCreate(id, requestDigest, 'request', 'start')
1911
+ try {
1912
+ return await runSessionOperation({ op: options.operation ?? 'create' }, async () => {
1913
+ try {
1914
+ const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
1915
+ traceSessionCreate(id, requestDigest, 'request', 'finish')
1916
+ return { status: 201, session }
1917
+ } catch (error) {
1918
+ if (error instanceof SessionMaintenanceError) throw error
1919
+ const failure = error instanceof SessionCreateError
1920
+ ? error
1921
+ : controller.signal.aborted
1922
+ ? createAbortError(controller.signal, 'request')
1923
+ : new SessionCreateError('session_create_failed', 'request', String((error as Error).message || error), 400)
1924
+ return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
1925
+ }
1926
+ })
1927
+ } finally {
1928
+ clearTimeout(timer)
1929
+ options.signal?.removeEventListener('abort', cancel)
1930
+ }
1812
1931
  }
1813
1932
 
1814
1933
  // @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
@@ -1816,30 +1935,76 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
1816
1935
  // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
1817
1936
  // bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
1818
1937
  // comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
1819
- // either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
1820
- // reachable do we fall back to launching in this process (with a stderr warning) the backend's own POST
1821
- // handler calls newSession directly, so it never re-enters this path.
1938
+ // either process.) So the CLI POSTs to the running backend whenever one answers. Only an explicit
1939
+ // ECONNREFUSED proves there is no owner on the target and permits the legacy in-process fallback. A timeout,
1940
+ // reset, DNS failure, or other ambiguous transport result may hide an admitted request, so it fails loud and
1941
+ // never starts a second writer. The backend POST, mention dispatch, and fallback all enter through
1942
+ // sessionCreateRequest; the private preparation half cannot be called without its bounded context.
1943
+ function isExplicitConnectionRefused(error: unknown): boolean {
1944
+ if (!error || typeof error !== 'object') return false
1945
+ if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true
1946
+ const errors = (error as { errors?: unknown }).errors
1947
+ if (Array.isArray(errors)) return errors.length > 0 && errors.every(isExplicitConnectionRefused)
1948
+ return isExplicitConnectionRefused((error as { cause?: unknown }).cause)
1949
+ }
1950
+ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean> {
1951
+ const controller = new AbortController()
1952
+ const timer = setTimeout(() => controller.abort(), 1500)
1953
+ timer.unref?.()
1954
+ try {
1955
+ const response = await fetch(`${target.url}/api/settings`, { signal: controller.signal })
1956
+ let settings: BackendSettings | null = null
1957
+ if (response.ok) {
1958
+ try { settings = await response.json() as BackendSettings }
1959
+ catch (error) { if (controller.signal.aborted) throw error }
1960
+ }
1961
+ assertProjectSettingsMatch('spex session new', target, settings)
1962
+ return false
1963
+ } catch (error) {
1964
+ if (isExplicitConnectionRefused(error)) return true
1965
+ const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`)
1966
+ failed.name = 'BackendError'
1967
+ Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error })
1968
+ throw failed
1969
+ } finally { clearTimeout(timer) }
1970
+ }
1822
1971
  export async function createSession(prompt: string, launcher?: string): Promise<Session> {
1823
1972
  if (maintenanceBrokerDescriptors()) {
1824
1973
  throw new SessionMaintenanceError('maintenance_capability_missing', 'maintenance operator broker admits only its exact stop/resume plan', { operation: 'create' })
1825
1974
  }
1826
- await assertProjectMatch('spex session new')
1827
1975
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
1828
1976
  // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
1829
1977
  // backend, whose process env carries no acting session id. An agent that runs `spex session new` stamps its own id;
1830
1978
  // a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
1831
1979
  const parent = ownSessionId()
1980
+ const requestKey = randomUUID()
1981
+ const target = await apiBaseInfo()
1982
+ const base = target.url
1983
+ const refused = await probeSessionCreateAuthority(target)
1984
+ if (refused) {
1985
+ console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
1986
+ const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey, operation: 'fallback-create' })
1987
+ if (fallback.status === 201) return fallback.session
1988
+ const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
1989
+ error.name = 'BackendError'
1990
+ throw error
1991
+ }
1992
+ const controller = new AbortController()
1993
+ const timer = setTimeout(() => controller.abort(new Error('backend session-create request timed out')), sessionCreateTimeoutMs() + 5_000)
1994
+ timer.unref?.()
1832
1995
  let res: Response
1833
1996
  try {
1834
- res = await fetch(`${await apiBase()}/api/sessions`, {
1997
+ res = await fetch(`${base}/api/sessions`, {
1835
1998
  method: 'POST',
1836
- headers: { 'content-type': 'application/json' },
1999
+ headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
1837
2000
  body: JSON.stringify({ prompt, parent, launcher }),
2001
+ signal: controller.signal,
1838
2002
  })
1839
- } catch {
1840
- console.error('spex: no backend reachable launching in-process (caller env owns auth, no concurrency cap)')
1841
- return runSessionOperation({ op: 'fallback-create' }, () => newSession(prompt, parent, launcher))
1842
- }
2003
+ } catch (error) {
2004
+ const failed = new Error(`backend session create failed without fallback after admission began: ${error instanceof Error ? error.message : error}`)
2005
+ failed.name = 'BackendError'
2006
+ throw failed
2007
+ } finally { clearTimeout(timer) }
1843
2008
  if (!res.ok) {
1844
2009
  const text = await res.text().catch(() => '')
1845
2010
  let msg = text
@@ -1868,80 +2033,416 @@ export function spawnerClause(p: SessRec | null): string {
1868
2033
  `read it there directly. Read only: never write into another session's worktree.`
1869
2034
  }
1870
2035
 
1871
- // @@@ newSession - durable worktree (branch node/<slug> off main) + a global session.json record. The agent does NOT
2036
+ function sessionCreateFailureRecord(rec: SessRec, error: unknown): SessRec {
2037
+ const msg = error instanceof Error ? error.message : String(error)
2038
+ console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
2039
+ return { ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` }
2040
+ }
2041
+
2042
+ // A materialize failure can leave a tracked contract or .gitignore half-written. Until publication this is
2043
+ // still creation-owned preparation: no worker can have authored work here, so restore HEAD and remove its
2044
+ // untracked artifacts rather than publish a queued record that close must preserve as possibly-user-dirty.
2045
+ // Disable checkout hooks: recovery is not another anchor that may recreate the failed materialization.
2046
+ async function resetFailedMaterializeCandidate(rec: SessRec, signal: AbortSignal): Promise<void> {
2047
+ throwIfCreateAborted(signal, 'materialize')
2048
+ const reset = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'reset', '--hard', '--quiet', 'HEAD'])
2049
+ if (!reset.ok) {
2050
+ const detail = (reset.stderr || reset.stdout || 'git reset failed without diagnostic').trim()
2051
+ throw new SessionCreateError('session_create_failed', 'materialize',
2052
+ `materialize failed and its prepared worktree could not be restored: ${detail}`, 500)
2053
+ }
2054
+ const clean = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'clean', '-fd', '-e', 'spexcode.local.json'])
2055
+ if (clean.ok) return
2056
+ const detail = (clean.stderr || clean.stdout || 'git clean failed without diagnostic').trim()
2057
+ throw new SessionCreateError('session_create_failed', 'materialize',
2058
+ `materialize failed and its prepared worktree could not be restored: ${detail}`, 500)
2059
+ }
2060
+
2061
+ async function materializeSessionCandidate(rec: SessRec, signal: AbortSignal): Promise<SessRec> {
2062
+ throwIfCreateAborted(signal, 'materialize')
2063
+ try {
2064
+ const req = createRequire(join(pkgRoot(), 'package.json'))
2065
+ const tsxImport = req.resolve('tsx/esm')
2066
+ await new Promise<void>((resolvePromise, reject) => {
2067
+ const child = spawn(process.execPath, ['--import', tsxImport, join(pkgRoot(), 'src', 'cli.ts'), 'materialize'], {
2068
+ cwd: rec.worktreePath,
2069
+ env: process.env,
2070
+ detached: true,
2071
+ stdio: ['ignore', 'ignore', 'pipe'],
2072
+ })
2073
+ let stderr = '', settled = false
2074
+ const killTree = () => {
2075
+ if (!child.pid) return
2076
+ try { process.kill(-child.pid, 'SIGKILL') } catch { /* process group already gone */ }
2077
+ try { child.kill('SIGKILL') } catch { /* child already gone */ }
2078
+ }
2079
+ const abort = () => killTree()
2080
+ signal.addEventListener('abort', abort, { once: true })
2081
+ child.stderr.setEncoding('utf8').on('data', (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk })
2082
+ child.once('error', (error) => {
2083
+ if (settled) return
2084
+ settled = true
2085
+ signal.removeEventListener('abort', abort)
2086
+ reject(error)
2087
+ })
2088
+ child.once('close', (code, childSignal) => {
2089
+ if (settled) return
2090
+ settled = true
2091
+ signal.removeEventListener('abort', abort)
2092
+ if (signal.aborted) { reject(createAbortError(signal, 'materialize')); return }
2093
+ if (code === 0) { resolvePromise(); return }
2094
+ reject(new Error(`materialize exited ${childSignal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`))
2095
+ })
2096
+ if (signal.aborted) abort()
2097
+ })
2098
+ return rec
2099
+ } catch (error) {
2100
+ if (signal.aborted || error instanceof SessionCreateError) throw createAbortError(signal, 'materialize')
2101
+ const failed = sessionCreateFailureRecord(rec, error)
2102
+ await resetFailedMaterializeCandidate(rec, signal)
2103
+ return failed
2104
+ }
2105
+ }
2106
+
2107
+ type SessionCandidateOwnership = { store: boolean; path: boolean; worktree: boolean; branch: boolean }
2108
+ type SessionCandidateState = { path: boolean; worktree: boolean; branch: boolean }
2109
+ type SessionCandidateStage = 'prepared' | 'git-created' | 'store-created'
2110
+ type SessionCandidateReceipt = {
2111
+ version: 1
2112
+ requestDigest: string
2113
+ payloadHash: string
2114
+ root: string
2115
+ path: string
2116
+ branch: string
2117
+ prestate: { store: false; path: false; worktree: false; branch: false }
2118
+ stage: SessionCandidateStage
2119
+ }
2120
+ type SessionCandidateReceiptRead =
2121
+ | { kind: 'absent' }
2122
+ | { kind: 'invalid'; error: string }
2123
+ | { kind: 'valid'; receipt: SessionCandidateReceipt }
2124
+
2125
+ const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
2126
+ const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
2127
+ const sessionCandidateLockId = (path: string, branch: string) => `create-resource-${digest(`${path}\0${branch}`)}`
2128
+ function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
2129
+ const path = sessionCandidateReceiptPath(id)
2130
+ if (!existsSync(path)) return { kind: 'absent' }
2131
+ try {
2132
+ const value = JSON.parse(readFileSync(path, 'utf8')) as Partial<SessionCandidateReceipt>
2133
+ const prestate = value.prestate
2134
+ if (value.version !== 1 || typeof value.requestDigest !== 'string' || typeof value.payloadHash !== 'string'
2135
+ || typeof value.root !== 'string' || typeof value.path !== 'string' || typeof value.branch !== 'string'
2136
+ || !prestate || prestate.store !== false || prestate.path !== false || prestate.worktree !== false || prestate.branch !== false
2137
+ || !['prepared', 'git-created', 'store-created'].includes(value.stage as string)) {
2138
+ return { kind: 'invalid', error: `invalid private candidate receipt at ${path}` }
2139
+ }
2140
+ return { kind: 'valid', receipt: value as SessionCandidateReceipt }
2141
+ } catch (error) {
2142
+ return { kind: 'invalid', error: `invalid private candidate receipt at ${path}: ${error instanceof Error ? error.message : error}` }
2143
+ }
2144
+ }
2145
+ function writeSessionCandidateReceipt(id: string, receipt: SessionCandidateReceipt): void {
2146
+ const dir = sessionCandidateReceiptDir()
2147
+ mkdirSync(dir, { recursive: true })
2148
+ const path = sessionCandidateReceiptPath(id)
2149
+ const tmp = join(dir, `.${id}.${process.pid}.${randomUUID()}.tmp`)
2150
+ try {
2151
+ writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 })
2152
+ renameSync(tmp, path)
2153
+ } finally { rmSync(tmp, { force: true }) }
2154
+ }
2155
+ function retireSessionCandidateReceipt(id: string): boolean {
2156
+ try { rmSync(sessionCandidateReceiptPath(id), { force: true }) } catch { return false }
2157
+ return !existsSync(sessionCandidateReceiptPath(id))
2158
+ }
2159
+ function sessionCandidateReceiptMatches(receipt: SessionCandidateReceipt, context: SessionCreateContext, root: string, path: string, branch: string): boolean {
2160
+ return receipt.requestDigest === context.requestDigest && receipt.payloadHash === context.payloadHash
2161
+ && receipt.root === root && receipt.path === path && receipt.branch === branch
2162
+ }
2163
+ function publishedSessionCandidateReceiptRetirementFailure(rec: SessRec, root: string): string | null {
2164
+ const durable = readSessionCandidateReceipt(rec.session)
2165
+ if (durable.kind === 'absent') return null
2166
+ if (durable.kind === 'invalid') return durable.error
2167
+ if (!rec.createRequestId || !rec.createPayloadHash || !rec.branch || !rec.worktreePath
2168
+ || durable.receipt.requestDigest !== rec.createRequestId || durable.receipt.payloadHash !== rec.createPayloadHash
2169
+ || durable.receipt.root !== root || durable.receipt.path !== rec.worktreePath || durable.receipt.branch !== rec.branch) {
2170
+ return `private candidate receipt at ${sessionCandidateReceiptPath(rec.session)} does not match the published record`
2171
+ }
2172
+ if (!retireSessionCandidateReceipt(rec.session)) return `private candidate receipt remains at ${sessionCandidateReceiptPath(rec.session)}`
2173
+ return readSessionCandidateReceipt(rec.session).kind === 'absent'
2174
+ ? null
2175
+ : `private candidate receipt retirement is unproven at ${sessionCandidateReceiptPath(rec.session)}`
2176
+ }
2177
+ async function retirePublishedSessionCandidateReceipt(rec: SessRec, context: SessionCreateContext): Promise<void> {
2178
+ if (!rec.branch || !rec.worktreePath) return
2179
+ if (readSessionCandidateReceipt(rec.session).kind === 'absent') return
2180
+ const root = mainRoot(), path = rec.worktreePath, branch = rec.branch
2181
+ await withRecordLock(sessionCandidateLockId(path, branch), async () => {
2182
+ const failure = publishedSessionCandidateReceiptRetirementFailure(rec, root)
2183
+ if (failure) console.error(`spex: published session ${rec.session.slice(0, 8)} remains the fence for its candidate receipt: ${failure}`)
2184
+ }, context.signal)
2185
+ }
2186
+ async function sessionCandidateState(root: string, path: string, branch: string, signal: AbortSignal): Promise<SessionCandidateState> {
2187
+ const [listed, ref] = await withGitAbortSignal(signal, () => Promise.all([
2188
+ gitTry(['-C', root, 'worktree', 'list', '--porcelain', '-z']),
2189
+ gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
2190
+ ]))
2191
+ if (!listed.ok) throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read worktree registry: ${listed.stderr.trim() || listed.failure}`, 500)
2192
+ if (!ref.ok && ref.failure !== 'exit') throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read candidate branch: ${ref.stderr.trim() || ref.failure}`, 500)
2193
+ return { path: existsSync(path), worktree: listed.stdout.split('\0').includes(`worktree ${path}`), branch: ref.ok }
2194
+ }
2195
+
2196
+ async function cleanupSessionCandidate(root: string, id: string, path: string, branch: string, owned: SessionCandidateOwnership): Promise<string[]> {
2197
+ const residues: string[] = []
2198
+ if (owned.store) {
2199
+ try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* verified below */ }
2200
+ }
2201
+ const controller = new AbortController()
2202
+ const timer = setTimeout(() => controller.abort(), 10_000)
2203
+ timer.unref?.()
2204
+ try {
2205
+ await withGitAbortSignal(controller.signal, async () => {
2206
+ if (owned.worktree) {
2207
+ const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', path])
2208
+ if (!removed.ok) residues.push(`worktree remove failed: ${removed.stderr.trim() || removed.failure}`)
2209
+ }
2210
+ const afterRemove = await sessionCandidateState(root, path, branch, controller.signal)
2211
+ if (owned.path && afterRemove.path && !afterRemove.worktree) {
2212
+ try { rmSync(path, { recursive: true, force: true }) } catch { /* verified below */ }
2213
+ }
2214
+ const ref = `refs/heads/${branch}`
2215
+ if (owned.branch) {
2216
+ const deleted = await gitTry(['-C', root, 'branch', '-D', branch])
2217
+ if (!deleted.ok) residues.push(`branch delete failed: ${deleted.stderr.trim() || deleted.failure}`)
2218
+ }
2219
+ const state = await sessionCandidateState(root, path, branch, controller.signal)
2220
+ if ((owned.path && state.path) || (owned.worktree && state.worktree)) residues.push(`owned worktree remains at ${path}`)
2221
+ if (owned.branch && state.branch) residues.push(`owned branch remains at ${ref}`)
2222
+ if ((!owned.path && state.path) || (!owned.worktree && state.worktree)) residues.push(`unowned candidate worktree preserved at ${path}`)
2223
+ if (!owned.branch && state.branch) residues.push(`unowned candidate branch preserved at ${ref}`)
2224
+ })
2225
+ } catch (error) {
2226
+ residues.push(`Git cleanup did not settle: ${error instanceof Error ? error.message : error}`)
2227
+ } finally { clearTimeout(timer) }
2228
+ if (owned.store && existsSync(sessionStoreDir(id))) residues.push(`owned session store remains at ${sessionStoreDir(id)}`)
2229
+ return residues
2230
+ }
2231
+
2232
+ function existingCreateReceipt(rec: SessRec): Session {
2233
+ const h = harnessById(rec.harness || defaultHarness.id)
2234
+ if (rec.status === 'queued') return toSession(rec, 'queued', 'offline')
2235
+ const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? PROPOSAL_STATUS[rec.proposal ?? 'nothing'] : rec.status
2236
+ return toSession(rec, status, rec.stopped ? 'offline' : h.headless ? 'online' : 'starting')
2237
+ }
2238
+
2239
+ async function proveSessionCandidate(path: string, branch: string, signal: AbortSignal): Promise<string | null> {
2240
+ const [top, checkedOut, ref] = await withGitAbortSignal(signal, () => Promise.all([
2241
+ gitTry(['-C', path, 'rev-parse', '--show-toplevel']),
2242
+ gitTry(['-C', path, 'symbolic-ref', '--quiet', '--short', 'HEAD']),
2243
+ gitTry(['-C', mainRoot(), 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
2244
+ ]))
2245
+ if (!top.ok || !checkedOut.ok || !ref.ok) return [top.stderr, checkedOut.stderr, ref.stderr].map((value) => value.trim()).filter(Boolean).join('; ') || 'Git identity validation failed'
2246
+ let actualTop = top.stdout.trim()
2247
+ try { actualTop = realpathSync(actualTop) } catch { /* missing path is reported by the comparison */ }
2248
+ let expectedTop = path
2249
+ try { expectedTop = realpathSync(path) } catch { /* missing path is reported by the comparison */ }
2250
+ if (actualTop !== expectedTop) return `worktree top-level is ${actualTop}, expected ${expectedTop}`
2251
+ if (checkedOut.stdout.trim() !== branch) return `worktree checked out ${checkedOut.stdout.trim() || '(detached)'}, expected ${branch}`
2252
+ return null
2253
+ }
2254
+
2255
+ // @@@ prepareSession - private required-context half of the bounded create owner. It prepares one durable
2256
+ // worktree (branch node/<slug> off main) + global session.json record. The agent does NOT
1872
2257
  // launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
1873
2258
  // immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
1874
2259
  // `spex session new`. Creating or deleting a spec node is NOT a server op — it is prompt-driven work the
1875
2260
  // launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
1876
2261
  // only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
1877
2262
  // programmatic surface, every other surface is prompt only).
1878
- export async function newSession(prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
1879
- const id = randomUUID()
1880
- // a launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below) AND the harness — so
1881
- // picking one is the ONLY launch choice. Explicit --launcher wins, else the configured defaultLauncher.
1882
- // A missing/unknown default throws fail-loud; there is no built-in-claude or harness fallback.
1883
- const lname = launcher ?? defaultLauncher(mainRoot())
1884
- const chosen = resolveLauncher(lname)
1885
- const h = harnessById(chosen.harness)
1886
- const pinned = h.baseCmd(chosen.cmd)
1887
- const rawPrompt = prompt
1888
- // node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
1889
- // plugin prose is payload only and can never invent scope.
1890
- const ref = nodeFromPrompt(rawPrompt)
1891
- const launchSpecs = ref ? await loadSpecs() : null
1892
- const title = ref ? null : titleFromPrompt(rawPrompt)
1893
- const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1894
- const branch = `node/${slug}`
1895
- const path = join(mainRoot(), '.worktrees', slug)
1896
- // Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
1897
- // preset resolution breaks. The optional spec + spawner pointers are seam inputs; the note insert remains last.
1898
- const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
1899
- const suffix = (spec ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.` : '')
1900
- + spawnerClause(parent ? readRecord(parent) : null)
1901
- const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
1902
- loadedSpecs: launchSpecs ?? undefined,
1903
- suffix: suffix || undefined,
1904
- })).text
1905
- await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1906
- // the checkout delivers the tracked spec sources and the materialize below delivers the materialized
1907
- // artifacts; the ONE
1908
- // thing git cannot carry is the machine-local spexcode.local.json — copied as a snapshot ([[residence]];
1909
- // no-op when the main checkout has none).
1910
- seedWorktreeHostState(mainRoot(), path)
1911
- // prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
1912
- // slot is free, else it waits — durable as a global record (+ its worktree), so it survives a backend
1913
- // restart and is still findable. governed:true — this is a DASHBOARD/CLI-launched session, so it feeds the
1914
- // board and the lifecycle hooks act on it; worktreePath/branch/createdAt are stamped here (the record, not
1915
- // the worktree, is the board's enumeration source now).
1916
- const rec: SessRec = {
1917
- session: id, governed: true, worktreePath: path, branch,
1918
- // parent = the SPAWNING session's id, captured ONCE here ([[session-nesting]]): a durable pointer, never
1919
- // mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
1920
- node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
1921
- status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
1922
- harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
1923
- // PIN the resolved launch command NOW ([[launcher-select]] resume-launcher-pin) so every future
1924
- // (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
1925
- // re-resolving against a default that may have flipped (a backend restarted under a different launcher).
1926
- launchCmd: pinned,
1927
- launchOwner: backendLaunchAuthority(),
1928
- }
1929
- writeRecord(rec)
1930
- writePromptFile(id, rawPrompt) // capture the ORIGINATING prompt (the human/manager's ask), not expanded plugin prose
1931
- // materialize the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
1932
- // shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
1933
- // self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
1934
- // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1935
- // own memory load too.
1936
- bootstrapMaterialize(rec)
1937
- writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
1938
- await drainQueue() // launch now if under the cap, else leave it queued for a free slot
1939
- const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
1940
- // Every headless adapter is record-backed and therefore online immediately; interactive adapters still need
1941
- // their real process/transport snapshot. Read that capability directly instead of smuggling a harness-mode
1942
- // probe through liveness with fake tmuxAlive=false.
1943
- const queued = after.status === 'queued'
1944
- return toSession(after, queued ? 'queued' : 'working', h.headless ? 'online' : queued ? 'offline' : 'starting')
2263
+ async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
2264
+ const { id, requestDigest, payloadHash, signal } = context
2265
+ let phase: SessionCreatePhase = 'creation-lock'
2266
+ let shouldDrain = false
2267
+ traceSessionCreate(id, requestDigest, phase, 'start')
2268
+ try {
2269
+ const receipt = await withRecordLock(id, async () => {
2270
+ const existing = readRecord(id)
2271
+ if (existing) {
2272
+ if (existing.createRequestId !== requestDigest || existing.createPayloadHash !== payloadHash) {
2273
+ throw new SessionCreateError('session_create_key_reused', 'creation-lock', 'Idempotency-Key is already bound to another session-create payload', 409)
2274
+ }
2275
+ await retirePublishedSessionCandidateReceipt(existing, context)
2276
+ return existingCreateReceipt(existing)
2277
+ }
2278
+
2279
+ phase = 'launcher-resolution'
2280
+ traceSessionCreate(id, requestDigest, phase, 'start')
2281
+ let chosen: ReturnType<typeof resolveLauncher>
2282
+ let h: Harness
2283
+ let pinned: string
2284
+ try {
2285
+ const lname = launcher ?? defaultLauncher(mainRoot())
2286
+ chosen = resolveLauncher(lname)
2287
+ h = harnessById(chosen.harness)
2288
+ pinned = h.baseCmd(chosen.cmd)
2289
+ } catch (error) {
2290
+ throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400)
2291
+ }
2292
+ traceSessionCreate(id, requestDigest, phase, 'finish')
2293
+
2294
+ phase = 'target-resolution'
2295
+ traceSessionCreate(id, requestDigest, phase, 'start')
2296
+ throwIfCreateAborted(signal, phase)
2297
+ const rawPrompt = prompt
2298
+ const ref = nodeFromPrompt(rawPrompt)
2299
+ const launchSpecs = ref ? loadSpecsLite() : null
2300
+ const title = ref ? null : titleFromPrompt(rawPrompt)
2301
+ const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
2302
+ const root = mainRoot()
2303
+ const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`
2304
+ const path = join(root, '.worktrees', slug)
2305
+ const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined
2306
+ const suffix = (spec ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.` : '')
2307
+ + spawnerClause(parent ? readRecord(parent) : null)
2308
+ let launchPrompt: string
2309
+ try {
2310
+ launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
2311
+ loadedSpecs: launchSpecs ?? undefined,
2312
+ suffix: suffix || undefined,
2313
+ })).text
2314
+ } catch (error) {
2315
+ throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400)
2316
+ }
2317
+ traceSessionCreate(id, requestDigest, phase, 'finish')
2318
+
2319
+ phase = 'git-worktree'
2320
+ traceSessionCreate(id, requestDigest, phase, 'start')
2321
+ const resourceLock = sessionCandidateLockId(path, branch)
2322
+ return await withRecordLock(resourceLock, async () => {
2323
+ throwIfCreateAborted(signal, phase)
2324
+ let before = await sessionCandidateState(root, path, branch, signal)
2325
+ let storePresent = existsSync(sessionStoreDir(id))
2326
+ const durable = readSessionCandidateReceipt(id)
2327
+ if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
2328
+ if (durable.kind === 'valid') {
2329
+ if (!sessionCandidateReceiptMatches(durable.receipt, context, root, path, branch)) {
2330
+ throw new SessionCreateError('session_create_failed', phase, 'private candidate receipt does not match this create request; preserving candidate resources', 409)
2331
+ }
2332
+ phase = 'cleanup'
2333
+ traceSessionCreate(id, requestDigest, phase, 'start', `recover-${durable.receipt.stage}`)
2334
+ const recovered = await cleanupSessionCandidate(root, id, path, branch, {
2335
+ store: storePresent, path: before.path, worktree: before.worktree, branch: before.branch,
2336
+ })
2337
+ traceSessionCreate(id, requestDigest, phase, recovered.length ? 'abort' : 'finish', recovered.join('; ') || undefined)
2338
+ if (recovered.length) throw new SessionCreateError('session_create_cleanup_failed', phase, `matching candidate recovery left residue: ${recovered.join('; ')}`, 500)
2339
+ before = { path: false, worktree: false, branch: false }
2340
+ storePresent = false
2341
+ phase = 'git-worktree'
2342
+ } else if (storePresent || before.path || before.worktree || before.branch) {
2343
+ const occupied = [storePresent ? `session store ${sessionStoreDir(id)}` : '', before.path ? `path ${path}` : '', before.worktree ? `registered worktree ${path}` : '', before.branch ? `branch ${branch}` : ''].filter(Boolean).join(', ')
2344
+ throw new SessionCreateError('session_create_failed', phase, `session target is already occupied: ${occupied}`, 409)
2345
+ }
2346
+ let candidateReceipt: SessionCandidateReceipt = {
2347
+ version: 1, requestDigest, payloadHash, root, path, branch,
2348
+ prestate: { store: false, path: false, worktree: false, branch: false }, stage: 'prepared',
2349
+ }
2350
+ writeSessionCandidateReceipt(id, candidateReceipt)
2351
+ const owned: SessionCandidateOwnership = { store: false, path: false, worktree: false, branch: false }
2352
+ let gitMutationStarted = false
2353
+ let published = false
2354
+ try {
2355
+ gitMutationStarted = true
2356
+ const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()]))
2357
+ if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
2358
+ if (!added.ok || !existsSync(path)) {
2359
+ throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
2360
+ }
2361
+ candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
2362
+ writeSessionCandidateReceipt(id, candidateReceipt)
2363
+ traceSessionCreate(id, requestDigest, phase, 'finish')
2364
+ seedWorktreeHostState(root, path)
2365
+
2366
+ let rec: SessRec = {
2367
+ session: id, governed: true, worktreePath: path, branch,
2368
+ node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
2369
+ status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
2370
+ harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
2371
+ launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
2372
+ }
2373
+ owned.store = true
2374
+ const dir = storeDir(id)
2375
+ writeFileSync(join(dir, 'prompt'), rawPrompt)
2376
+ writeFileSync(join(dir, 'launch'), launchPrompt)
2377
+ candidateReceipt = { ...candidateReceipt, stage: 'store-created' }
2378
+ writeSessionCandidateReceipt(id, candidateReceipt)
2379
+
2380
+ phase = 'materialize'
2381
+ traceSessionCreate(id, requestDigest, phase, 'start')
2382
+ rec = await materializeSessionCandidate(rec, signal)
2383
+ traceSessionCreate(id, requestDigest, phase, 'finish')
2384
+
2385
+ phase = 'record-write'
2386
+ traceSessionCreate(id, requestDigest, phase, 'start')
2387
+ throwIfCreateAborted(signal, phase)
2388
+ const gitMismatch = await proveSessionCandidate(path, branch, signal)
2389
+ if (gitMismatch) throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500)
2390
+ throwIfCreateAborted(signal, phase)
2391
+ writeRecord(rec)
2392
+ published = true
2393
+ const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root)
2394
+ if (receiptFailure) console.error(`spex: published session ${id.slice(0, 8)} remains the fence for its candidate receipt: ${receiptFailure}`)
2395
+ shouldDrain = true
2396
+ traceSessionCreate(id, requestDigest, phase, 'publish')
2397
+ return toSession(rec, 'queued', 'offline')
2398
+ } catch (error) {
2399
+ if (published) throw error
2400
+ const failurePhase = error instanceof SessionCreateError ? error.phase : phase
2401
+ let ownershipFailure: string | null = null
2402
+ if (gitMutationStarted && !(owned.path && owned.worktree && owned.branch)) {
2403
+ const inspection = new AbortController()
2404
+ const timer = setTimeout(() => inspection.abort(), 10_000)
2405
+ timer.unref?.()
2406
+ try {
2407
+ const after = await sessionCandidateState(root, path, branch, inspection.signal)
2408
+ owned.path ||= !before.path && after.path
2409
+ owned.worktree ||= !before.worktree && after.worktree
2410
+ owned.branch ||= !before.branch && after.branch
2411
+ } catch (inspectionError) {
2412
+ ownershipFailure = `candidate ownership inspection failed: ${inspectionError instanceof Error ? inspectionError.message : inspectionError}`
2413
+ } finally { clearTimeout(timer) }
2414
+ }
2415
+ phase = 'cleanup'
2416
+ traceSessionCreate(id, requestDigest, phase, 'start')
2417
+ const residues = await cleanupSessionCandidate(root, id, path, branch, owned)
2418
+ if (ownershipFailure) residues.unshift(ownershipFailure)
2419
+ if (!residues.length && !retireSessionCandidateReceipt(id)) residues.push(`private candidate receipt remains at ${sessionCandidateReceiptPath(id)}`)
2420
+ traceSessionCreate(id, requestDigest, phase, residues.length ? 'abort' : 'finish', residues.join('; ') || undefined)
2421
+ if (residues.length) throw new SessionCreateError('session_create_cleanup_failed', phase, `session creation failed and cleanup left residue: ${residues.join('; ')}`, 500)
2422
+ if (signal.aborted) { phase = failurePhase; throw createAbortError(signal, failurePhase) }
2423
+ throw error instanceof SessionCreateError
2424
+ ? error
2425
+ : new SessionCreateError('session_create_failed', failurePhase, error instanceof Error ? error.message : String(error), 500)
2426
+ }
2427
+ }, signal)
2428
+ }, signal)
2429
+ traceSessionCreate(id, requestDigest, 'creation-lock', 'finish')
2430
+ if (shouldDrain) {
2431
+ phase = 'launcher-queue'
2432
+ traceSessionCreate(id, requestDigest, phase, 'start')
2433
+ requestQueueDrain()
2434
+ traceSessionCreate(id, requestDigest, phase, 'finish')
2435
+ }
2436
+ return receipt
2437
+ } catch (error) {
2438
+ const failure = signal.aborted
2439
+ ? createAbortError(signal, phase)
2440
+ : error instanceof SessionCreateError
2441
+ ? error
2442
+ : new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 500)
2443
+ traceSessionCreate(id, requestDigest, failure.phase, 'abort', failure.code)
2444
+ throw failure
2445
+ }
1945
2446
  }
1946
2447
 
1947
2448
  // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
@@ -1956,9 +2457,7 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
1956
2457
  try {
1957
2458
  doMaterialize(rec.worktreePath)
1958
2459
  } catch (e) {
1959
- const msg = e instanceof Error ? e.message : String(e)
1960
- console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
1961
- writeRecord({ ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` })
2460
+ writeRecord(sessionCreateFailureRecord(rec, e))
1962
2461
  }
1963
2462
  }
1964
2463
 
@@ -2795,23 +3294,10 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
2795
3294
  // so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
2796
3295
  // A corrupt record proves no adapter, leaf, worktree, or branch owner. Close may copy those bytes to the
2797
3296
  // control-plane quarantine, but then fails before this teardown seam and names every residue it preserved.
2798
- async function closeSessionUnlocked(id: string): Promise<boolean> {
2799
- let wt: { path: string; branch: string | null; rec: SessRec } | null = null
2800
- try { wt = await findWorktree(id) }
2801
- catch (e) {
2802
- if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
2803
- const quarantined = quarantineRecord(id)
2804
- const runtime = sessionStoreDir(id)
2805
- const evidence = quarantined
2806
- ? `Original bytes were copied to ${quarantined}`
2807
- : `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`
2808
- let guard = 'no readable session record proves the adapter or leaf owner'
2809
- try { await stopAgentProcess(id, null) }
2810
- catch (error) { guard = error instanceof Error ? error.message : String(error) }
2811
- throw new SessionRecordUnusable('corrupt', id,
2812
- `refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`)
2813
- }
2814
- if (!wt) return false
3297
+ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
3298
+ const root = mainRoot()
3299
+ const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
3300
+ if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
2815
3301
  if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
2816
3302
  else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
2817
3303
  else await stopAgentProcess(id, wt.rec)
@@ -2819,17 +3305,17 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
2819
3305
  try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
2820
3306
  // a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
2821
3307
  if (existsSync(wt.path)) {
2822
- const removed = await gitTry(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
3308
+ const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', wt.path])
2823
3309
  if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`)
2824
3310
  if (existsSync(wt.path)) throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`)
2825
3311
  }
2826
3312
  if (wt.branch) {
2827
3313
  const branchRef = `refs/heads/${wt.branch}`
2828
- const present = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', '--quiet', branchRef])
3314
+ const present = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef])
2829
3315
  if (present.ok) {
2830
- const removed = await gitTry(['-C', mainRoot(), 'branch', '-D', wt.branch])
3316
+ const removed = await gitTry(['-C', root, 'branch', '-D', wt.branch])
2831
3317
  if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`)
2832
- const remaining = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', '--quiet', branchRef])
3318
+ const remaining = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef])
2833
3319
  if (remaining.ok || remaining.failure !== 'exit') throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`)
2834
3320
  } else if (present.failure !== 'exit') {
2835
3321
  throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`)
@@ -2842,10 +3328,223 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
2842
3328
  requestQueueDrain() // a close frees a slot — start the next queued session if any
2843
3329
  return true
2844
3330
  }
3331
+ async function closeSessionUnlocked(id: string): Promise<boolean> {
3332
+ let wt: { path: string; branch: string | null; rec: SessRec } | null = null
3333
+ try { wt = await findWorktree(id) }
3334
+ catch (e) {
3335
+ if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
3336
+ const quarantined = quarantineRecord(id)
3337
+ const runtime = sessionStoreDir(id)
3338
+ const evidence = quarantined
3339
+ ? `Original bytes were copied to ${quarantined}`
3340
+ : `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`
3341
+ let guard = 'no readable session record proves the adapter or leaf owner'
3342
+ try { await stopAgentProcess(id, null) }
3343
+ catch (error) { guard = error instanceof Error ? error.message : String(error) }
3344
+ throw new SessionRecordUnusable('corrupt', id,
3345
+ `refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`)
3346
+ }
3347
+ if (!wt) return false
3348
+ const target = wt
3349
+ return target.branch
3350
+ ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target))
3351
+ : closeOwnedSessionUnlocked(id, target)
3352
+ }
2845
3353
  export const closeSession = (id: string): Promise<boolean> =>
2846
3354
  runSessionOperation({ op: 'close', sessionId: id },
2847
3355
  () => withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id))))
2848
3356
 
3357
+ export type CorruptRecordQuarantineWitness = {
3358
+ adapter: string
3359
+ thread: string | null
3360
+ tmux: string
3361
+ worktree: string
3362
+ branch: string
3363
+ }
3364
+
3365
+ export type CorruptRecordQuarantineResult = {
3366
+ id: string
3367
+ bundle: string
3368
+ sha256: string
3369
+ observedAt: string
3370
+ }
3371
+
3372
+ const quarantineRoot = (id: string) => join(runtimeRoot(), 'corrupt', id)
3373
+ const recordSha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')
3374
+
3375
+ function normalizeQuarantineWitness(id: string, raw: unknown): CorruptRecordQuarantineWitness {
3376
+ if (!raw || typeof raw !== 'object') throw new ResourceConflict(`refusing to quarantine ${id}: an exact adapter/thread/tmux/worktree/branch witness is required`)
3377
+ const value = raw as Record<string, unknown>
3378
+ const text = (key: keyof CorruptRecordQuarantineWitness): string => typeof value[key] === 'string' ? value[key].trim() : ''
3379
+ const adapter = text('adapter')
3380
+ const tmux = text('tmux')
3381
+ const worktree = text('worktree')
3382
+ const branch = text('branch')
3383
+ if (!Object.prototype.hasOwnProperty.call(value, 'thread'))
3384
+ throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be explicit (a string or null)`)
3385
+ const threadValue = value.thread
3386
+ const thread = typeof threadValue === 'string' && threadValue.trim() ? threadValue.trim() : threadValue == null || threadValue === '' ? null : null
3387
+ if (!adapter || !HARNESSES.some((h) => h.id === adapter)) throw new ResourceConflict(`refusing to quarantine ${id}: adapter must name one registered harness`)
3388
+ if (tmux !== id) throw new ResourceConflict(`refusing to quarantine ${id}: tmux witness must be the exact session id ${id}`)
3389
+ if (!worktree || !isAbsolute(worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: worktree witness must be an absolute path`)
3390
+ if (!branch || branch.startsWith('-') || branch.startsWith('refs/')) throw new ResourceConflict(`refusing to quarantine ${id}: branch witness must be one local branch name`)
3391
+ if (threadValue !== undefined && threadValue !== null && typeof threadValue !== 'string') throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be a string or null`)
3392
+ return { adapter, thread, tmux, worktree: resolve(worktree), branch }
3393
+ }
3394
+
3395
+ async function proveQuarantineTmuxAbsent(id: string): Promise<{ state: 'absent' }> {
3396
+ try { await tmux(['has-session', '-t', id], TMUX_PROBE_TIMEOUT_MS) }
3397
+ catch (error) {
3398
+ if (probeTimedOut(error)) throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (probe timed out)`)
3399
+ if (typeof (error as NodeJS.ErrnoException).code === 'number') return { state: 'absent' }
3400
+ throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (${error instanceof Error ? error.message : String(error)})`)
3401
+ }
3402
+ throw new ResourceConflict(`refusing to quarantine ${id}: exact tmux session ${id} is live`)
3403
+ }
3404
+
3405
+ async function proveQuarantineGitAbsent(id: string, witness: CorruptRecordQuarantineWitness): Promise<{
3406
+ worktree: { state: 'absent' }
3407
+ branch: { state: 'absent' }
3408
+ }> {
3409
+ if (existsSync(witness.worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} is live`)
3410
+ const root = mainRoot()
3411
+ const listed = await gitTry(['-C', root, 'worktree', 'list', '--porcelain'])
3412
+ if (!listed.ok) throw new ResourceConflict(`refusing to quarantine ${id}: worktree registry is unknown`)
3413
+ const registered = listed.stdout.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => resolve(line.slice('worktree '.length)))
3414
+ if (registered.includes(witness.worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} remains registered`)
3415
+ const branch = await gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${witness.branch}`])
3416
+ if (branch.ok) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed branch ${witness.branch} is live`)
3417
+ if (branch.failure !== 'exit') throw new ResourceConflict(`refusing to quarantine ${id}: branch absence is unknown`)
3418
+ return { worktree: { state: 'absent' }, branch: { state: 'absent' } }
3419
+ }
3420
+
3421
+ function proveQuarantineLeafAbsent(id: string): { state: 'absent'; artifact: 'missing' | `stale:${number}` } {
3422
+ const path = sessionArtifactPath(id, 'agent.pid')
3423
+ let text: string
3424
+ try { text = readFileSync(path, 'utf8').trim() }
3425
+ catch (error) {
3426
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { state: 'absent', artifact: 'missing' }
3427
+ throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is unreadable`)
3428
+ }
3429
+ const pid = Number(text)
3430
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is malformed`)
3431
+ const start = processStartToken(pid)
3432
+ if (start) throw new ResourceConflict(`refusing to quarantine ${id}: registered agent process ${pid}@${start} is live or recycled`)
3433
+ return { state: 'absent', artifact: `stale:${pid}` }
3434
+ }
3435
+
3436
+ async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuarantineWitness): Promise<{
3437
+ adapter: string
3438
+ thread: string | null
3439
+ action: 'absent' | 'archived' | 'already-unloaded'
3440
+ compensate: () => Promise<{ ok: true } | { ok: false; reason: string }>
3441
+ }> {
3442
+ const harness = harnessById(witness.adapter)
3443
+ if (harness.ownsRendezvous) {
3444
+ const socket = await rendezvousListening(id)
3445
+ if (socket === 'live') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport is live`)
3446
+ if (socket === 'unproven') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport absence is unknown`)
3447
+ }
3448
+ if (witness.thread) {
3449
+ if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} cannot prove and unload an exact native thread`)
3450
+ const native = await harness.quarantineOrphanThread(witness.thread, { excludingSessionId: id })
3451
+ if (!native.ok) throw new ResourceConflict(`refusing to quarantine ${id}: ${native.reason}`)
3452
+ return { adapter: native.audit.adapter, thread: native.audit.threadId, action: native.audit.action, compensate: native.compensate }
3453
+ }
3454
+ const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? []
3455
+ for (const descriptor of descriptors) {
3456
+ if (!descriptor.residency) throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} has no exact absence census`)
3457
+ let residency: Awaited<ReturnType<NonNullable<typeof descriptor.residency>>>
3458
+ try { residency = await descriptor.residency() }
3459
+ catch (error) { throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${error instanceof Error ? error.message : String(error)})`) }
3460
+ if (!residency.healthy) throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${residency.error || 'unhealthy census'})`)
3461
+ if (!residency.rootAbsent || residency.referenceIds.length)
3462
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} is live; supply its exact native thread instead of claiming absence`)
3463
+ }
3464
+ return { adapter: harness.id, thread: null, action: 'absent', compensate: async () => ({ ok: true }) }
3465
+ }
3466
+
3467
+ // @@@ quarantineCorruptRecord - the record-only escape hatch for an incident that has already lost parseability.
3468
+ // It never guesses from those bytes: the caller names the former residues, this function proves their absence,
3469
+ // and only then moves the opaque file into an auditable bundle. Close remains the destructive owner-based verb.
3470
+ export async function quarantineCorruptRecord(id: string, rawWitness: unknown): Promise<CorruptRecordQuarantineResult> {
3471
+ return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
3472
+ const entry = readRecordEntry(id)
3473
+ if (entry.kind === 'absent') throw new ResourceConflict(`refusing to quarantine ${id}: no active session record exists`)
3474
+ if (entry.kind === 'ok') throw new ResourceConflict(`refusing to quarantine ${id}: record is readable; use its ordinary lifecycle control`)
3475
+ const witness = normalizeQuarantineWitness(id, rawWitness)
3476
+ const original = readFileSync(entry.path)
3477
+ const sha256 = recordSha256(original)
3478
+ const observedAt = new Date().toISOString()
3479
+ const leaf = proveQuarantineLeafAbsent(id)
3480
+ const tmux = await proveQuarantineTmuxAbsent(id)
3481
+ const git = await proveQuarantineGitAbsent(id, witness)
3482
+ const adapter = await proveQuarantineAdapter(id, witness)
3483
+ const bundle = join(quarantineRoot(id), `${observedAt.replace(/[:.]/g, '-')}-${randomUUID()}`)
3484
+ const stored = join(bundle, 'session.json')
3485
+ const provenance = join(bundle, 'provenance.json')
3486
+ const audit = {
3487
+ version: 1,
3488
+ sessionId: id,
3489
+ observedAt,
3490
+ record: { activePath: entry.path, sha256, bytes: original.length },
3491
+ witness,
3492
+ observed: { leaf, tmux, ...git, adapter: { adapter: adapter.adapter, thread: adapter.thread, action: adapter.action } },
3493
+ }
3494
+ try {
3495
+ mkdirSync(bundle, { recursive: true, mode: 0o700 })
3496
+ const temp = `${provenance}.${process.pid}.${randomUUID()}.tmp`
3497
+ writeFileSync(temp, `${JSON.stringify(audit, null, 2)}\n`, { mode: 0o600 })
3498
+ renameSync(temp, provenance)
3499
+ const current = readRecordEntry(id)
3500
+ if (current.kind !== 'corrupt' || current.path !== entry.path) throw new ResourceConflict(`refusing to quarantine ${id}: active record changed during absence verification`)
3501
+ const currentBytes = readFileSync(current.path)
3502
+ if (recordSha256(currentBytes) !== sha256) throw new ResourceConflict(`refusing to quarantine ${id}: opaque record changed during absence verification`)
3503
+ renameSync(current.path, stored)
3504
+ if (recordSha256(readFileSync(stored)) !== sha256) {
3505
+ renameSync(stored, current.path)
3506
+ throw new ResourceConflict(`refusing to quarantine ${id}: moved record failed byte-exact verification`)
3507
+ }
3508
+ } catch (error) {
3509
+ const restored = await adapter.compensate()
3510
+ const suffix = restored.ok ? '' : `; native orphan compensation failed: ${restored.reason}`
3511
+ if (error instanceof ResourceConflict) throw new ResourceConflict(`${error.message}${suffix}`)
3512
+ throw new ResourceConflict(`refusing to quarantine ${id}: ${error instanceof Error ? error.message : String(error)}${suffix}`)
3513
+ }
3514
+ return { id, bundle, sha256, observedAt }
3515
+ })))
3516
+ }
3517
+
3518
+ export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecordQuarantineResult> {
3519
+ return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
3520
+ if (readRecordEntry(id).kind !== 'absent') throw new ResourceConflict(`refusing to restore ${id}: an active session record already exists`)
3521
+ let bundles: string[]
3522
+ try { bundles = readdirSync(quarantineRoot(id), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse() }
3523
+ catch (error) {
3524
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ResourceConflict(`refusing to restore ${id}: no quarantine bundle exists`)
3525
+ throw new ResourceConflict(`refusing to restore ${id}: quarantine bundle inventory is unreadable`)
3526
+ }
3527
+ const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'session.json')) && existsSync(join(path, 'provenance.json')))
3528
+ if (!bundle) throw new ResourceConflict(`refusing to restore ${id}: no complete quarantine bundle exists`)
3529
+ const stored = join(bundle, 'session.json')
3530
+ let provenance: { sessionId?: unknown; record?: { sha256?: unknown } }
3531
+ try { provenance = JSON.parse(readFileSync(join(bundle, 'provenance.json'), 'utf8')) }
3532
+ catch { throw new ResourceConflict(`refusing to restore ${id}: quarantine provenance is unreadable`) }
3533
+ const bytes = readFileSync(stored)
3534
+ if (provenance.sessionId !== id || typeof provenance.record?.sha256 !== 'string' || provenance.record.sha256 !== recordSha256(bytes))
3535
+ throw new ResourceConflict(`refusing to restore ${id}: quarantine payload/provenance binding is invalid`)
3536
+ const active = sessionRecordPath(id)
3537
+ mkdirSync(dirname(active), { recursive: true })
3538
+ try { renameSync(stored, active) }
3539
+ catch (error) { throw new ResourceConflict(`refusing to restore ${id}: opaque record move failed (${error instanceof Error ? error.message : String(error)})`) }
3540
+ if (recordSha256(readFileSync(active)) !== provenance.record.sha256) {
3541
+ renameSync(active, stored)
3542
+ throw new ResourceConflict(`refusing to restore ${id}: restored record failed byte-exact verification`)
3543
+ }
3544
+ return { id, bundle, sha256: provenance.record.sha256, observedAt: new Date().toISOString() }
3545
+ })))
3546
+ }
3547
+
2849
3548
  // @@@ quarantine - closing sweeps the session's whole store dir, so an UNREADABLE record would take the only
2850
3549
  // evidence of what corrupted it with it. Copy those bytes to the per-project `corrupt/` shelf first, named by
2851
3550
  // session id and close time. Only unreadable records are shelved (a healthy one's contents are already known
@@ -3165,7 +3864,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
3165
3864
  // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
3166
3865
  // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
3167
3866
  // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
3168
- async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
3867
+ async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
3169
3868
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
3170
3869
  const rec = readRecord(id)
3171
3870
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
@@ -3181,7 +3880,7 @@ async function sendTextUnlocked(id: string, text: string, from?: string, opts: {
3181
3880
  } catch { /* no pane to consult — let the delivery channel decide */ }
3182
3881
  }
3183
3882
  const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3184
- const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, prompt.text)
3883
+ const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), ...(opts.deliveryId ? { deliveryId: opts.deliveryId } : {}) }, prompt.text)
3185
3884
  // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
3186
3885
  // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
3187
3886
  if (r.ok && from) void recordComms(id, from)
@@ -3189,10 +3888,69 @@ async function sendTextUnlocked(id: string, text: string, from?: string, opts: {
3189
3888
  if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
3190
3889
  return r
3191
3890
  }
3192
- export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
3193
- // The record lock spans the delivery RPC. Archive preflight and leaf teardown cannot race a product turn
3194
- // start/steer/input from another CLI process and then discover it only after killing the pane.
3195
- return runSessionOperation({ op: 'send', sessionId: id }, () => withRecordLock(id, () => sendTextUnlocked(id, text, from, opts)))
3891
+
3892
+ // A native request can commit after its client loses the response. Reserve a caller's opaque marker BEFORE
3893
+ // crossing that boundary, under the session lock, so a retry has one safe answer after a backend restart: the
3894
+ // stored terminal result, or commit-unknown for a reservation whose result could not be durably recorded.
3895
+ // This is session-generic idempotency; adapters only decide whether they can also carry the marker natively.
3896
+ type DeliveryLedgerEntry = { deliveryId: string; fingerprint: string; result?: DispatchResult }
3897
+ const deliveryLedgerPath = (id: string) => sessionArtifactPath(id, 'deliveries.ndjson')
3898
+ const deliveryFingerprint = (text: string, from?: string, replyVia?: 'note') =>
3899
+ createHash('sha256').update(JSON.stringify([text, from ?? null, replyVia ?? null])).digest('hex')
3900
+ function readDeliveryLedger(id: string, deliveryId: string): DeliveryLedgerEntry | null {
3901
+ try {
3902
+ const lines = readFileSync(deliveryLedgerPath(id), 'utf8').split('\n')
3903
+ for (let index = lines.length - 1; index >= 0; index--) {
3904
+ try {
3905
+ const entry = JSON.parse(lines[index]) as DeliveryLedgerEntry
3906
+ if (entry?.deliveryId === deliveryId && typeof entry.fingerprint === 'string') return entry
3907
+ } catch { /* one corrupt append must not invent a delivery result */ }
3908
+ }
3909
+ } catch { /* no delivery ledger yet */ }
3910
+ return null
3911
+ }
3912
+ function appendDeliveryLedger(id: string, entry: DeliveryLedgerEntry): void {
3913
+ appendFileSync(join(storeDir(id), 'deliveries.ndjson'), JSON.stringify(entry) + '\n')
3914
+ }
3915
+ function terminalDispatch(result: DispatchResult): DispatchResult {
3916
+ return { ...result, outcome: result.outcome ?? (result.ok ? 'accepted' : 'rejected') }
3917
+ }
3918
+ export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
3919
+ // The lock owns only the durable reservation and result. Codex's native turn can synchronously run hooks
3920
+ // that write this same record, so holding it across an adapter RPC deadlocks the app-server's confirmation.
3921
+ return runSessionOperation({ op: 'send', sessionId: id }, async () => {
3922
+ const deliveryId = opts.deliveryId?.trim()
3923
+ if (!deliveryId) return terminalDispatch(await sendTextUnlocked(id, text, from, opts))
3924
+ const fingerprint = deliveryFingerprint(text, from, opts.replyVia)
3925
+ const prior = await withRecordLock(id, async () => {
3926
+ const existing = readDeliveryLedger(id, deliveryId)
3927
+ if (existing) return existing
3928
+ try {
3929
+ appendDeliveryLedger(id, { deliveryId, fingerprint })
3930
+ return null
3931
+ } catch (error) {
3932
+ return { deliveryId, fingerprint, result: { ok: false, outcome: 'rejected', error: `could not reserve delivery marker: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` } satisfies DispatchResult }
3933
+ }
3934
+ })
3935
+ if (prior) {
3936
+ if (prior.fingerprint !== fingerprint)
3937
+ return { ok: false, outcome: 'rejected', error: 'delivery marker belongs to a different prompt — prompt NOT delivered' }
3938
+ return prior.result
3939
+ ? terminalDispatch(prior.result)
3940
+ : { ok: false, outcome: 'commit-unknown', error: 'delivery marker is already reserved without a terminal outcome — prompt NOT replayed' }
3941
+ }
3942
+ const result = terminalDispatch(await sendTextUnlocked(id, text, from, { ...opts, deliveryId }))
3943
+ try {
3944
+ return await withRecordLock(id, async () => {
3945
+ if (!readRecord(id))
3946
+ return { ok: false, outcome: 'commit-unknown', error: 'session closed before delivery outcome could be recorded — prompt NOT replayed' }
3947
+ appendDeliveryLedger(id, { deliveryId, fingerprint, result })
3948
+ return result
3949
+ })
3950
+ } catch (error) {
3951
+ return { ok: false, outcome: 'commit-unknown', error: `delivery outcome could not be recorded: ${error instanceof Error ? error.message : String(error)} — prompt NOT replayed` }
3952
+ }
3953
+ })
3196
3954
  }
3197
3955
 
3198
3956
  // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a