spexcode 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +122 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/delivery-queue.ts +107 -0
  15. package/spec-cli/src/doctor.ts +0 -16
  16. package/spec-cli/src/gateway-auth.ts +0 -13
  17. package/spec-cli/src/gateway.ts +0 -22
  18. package/spec-cli/src/git.ts +151 -48
  19. package/spec-cli/src/graph.ts +28 -7
  20. package/spec-cli/src/graphCache.ts +253 -72
  21. package/spec-cli/src/graphDelta.ts +0 -13
  22. package/spec-cli/src/graphStream.ts +70 -37
  23. package/spec-cli/src/guide.ts +29 -6
  24. package/spec-cli/src/harness.ts +232 -248
  25. package/spec-cli/src/help.ts +27 -26
  26. package/spec-cli/src/host-resources.ts +11 -2
  27. package/spec-cli/src/index.ts +71 -95
  28. package/spec-cli/src/issues-cli.ts +276 -0
  29. package/spec-cli/src/issues.ts +15 -197
  30. package/spec-cli/src/layout.ts +78 -2
  31. package/spec-cli/src/lint.ts +5 -3
  32. package/spec-cli/src/localIssues.ts +22 -185
  33. package/spec-cli/src/loop-in.ts +78 -0
  34. package/spec-cli/src/materialize.ts +0 -42
  35. package/spec-cli/src/mentions.ts +49 -193
  36. package/spec-cli/src/opencode-headless.ts +7 -7
  37. package/spec-cli/src/opencode.ts +2 -5
  38. package/spec-cli/src/pi-headless.ts +5 -5
  39. package/spec-cli/src/pty-bridge.ts +4 -5
  40. package/spec-cli/src/reviewSnapshot.ts +1 -0
  41. package/spec-cli/src/reviews.ts +6 -1
  42. package/spec-cli/src/root-lru.ts +54 -0
  43. package/spec-cli/src/runtime-guard.ts +0 -9
  44. package/spec-cli/src/session-cursors.ts +91 -0
  45. package/spec-cli/src/session-follow.ts +182 -0
  46. package/spec-cli/src/session-timeline.ts +55 -127
  47. package/spec-cli/src/sessions.ts +235 -1021
  48. package/spec-cli/src/shim-runtime.ts +9 -16
  49. package/spec-cli/src/specs.ts +33 -10
  50. package/spec-cli/src/uninstall.ts +0 -9
  51. package/spec-cli/src/uploads.ts +240 -18
  52. package/spec-cli/src/worktree-sources.ts +0 -16
  53. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  54. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
  56. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  58. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  59. package/spec-cli/templates/spexcode.json +12 -0
  60. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  61. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  62. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  63. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  64. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  65. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  66. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  67. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  68. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  69. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  70. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  71. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  72. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  73. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  74. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  75. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  76. package/spec-dashboard/dist/index.html +2 -2
  77. package/spec-dashboard/src/session.js +1 -28
  78. package/spec-eval/src/cache.ts +0 -14
  79. package/spec-eval/src/cli.ts +26 -5
  80. package/spec-eval/src/evaltab.ts +67 -108
  81. package/spec-eval/src/filing.ts +0 -18
  82. package/spec-eval/src/freshness.ts +78 -32
  83. package/spec-eval/src/humanok.ts +0 -15
  84. package/spec-eval/src/scenariofresh.ts +8 -80
  85. package/spec-eval/src/scenarios.ts +16 -67
  86. package/spec-eval/src/sessioneval.ts +157 -78
  87. package/spec-eval/src/sidecar.ts +1 -70
  88. package/spec-eval/src/timeline.ts +0 -23
  89. package/spec-forge/src/cache.ts +34 -12
  90. package/spec-forge/src/cli.ts +0 -6
  91. package/spec-forge/src/drivers/github.ts +1 -12
  92. package/spec-forge/src/drivers/gitlab.ts +4 -19
  93. package/spec-forge/src/drivers.ts +0 -18
  94. package/spec-forge/src/links.ts +0 -4
  95. package/spec-forge/src/needs-eval.ts +0 -1
  96. package/spec-forge/src/port.ts +0 -12
  97. package/spec-forge/src/resident.ts +4 -20
  98. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  99. package/spec-cli/src/session-maintenance.ts +0 -751
  100. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  101. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  102. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  103. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  104. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -10,62 +10,19 @@ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbor
10
10
  import { loadConfig, loadSpecs, loadSpecsLite, type ConfigPreset, type SpecLite } from './specs.js'
11
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'
12
12
  import { materialize } from './materialize.js'
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'
14
- import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
13
+ import { mainBranch, mainRoot, 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'
14
+ import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
15
+ import { drain, enqueue, owesDelivery } from './delivery-queue.js'
15
16
  import { stripRefSigil } from './mentions.js'
16
17
  import { shQuote } from './sh.js'
17
18
  import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
18
19
  import { processStartToken } from './process-identity.js'
19
- import { maintenanceBrokerDescriptors, runSessionOperation, runSessionOperationSync, SessionMaintenanceError, type Authorization, type MaintenanceTicket } from './session-maintenance.js'
20
-
21
- // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
22
- // SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
23
- // session_id (NOT a worktree file — the worktree stays pristine), surviving a kill / reboot / moving the
24
- // folder. We launch claude with `--session-id <id>` (id we choose) so the SAME conversation can be
25
- // `--resume`d into a fresh tmux. NO in-memory map: listSessions() ENUMERATES that store every time.
26
- //
27
- // STATE MACHINE — two ORTHOGONAL axes (see [[state]]): an agent-authored LIFECYCLE and a runtime-derived
28
- // LIVENESS, neither overriding the other.
29
- // lifecycle (authored): active | idle | awaiting | parked | error | asking | queued. `idle` is the ONE
30
- // inferred one (the Notification(idle_prompt) hook, guarded active-only so it never clobbers a
31
- // declaration; mark-active flips it back to active on real work).
32
- // liveness (derived for EVERY session): online | starting | offline | unknown. offline = no tmux for the id,
33
- // or the harness online-signal (claude's rendezvous socket LISTENER — a connect, not the socket
34
- // FILE) is gone past the boot grace; starting = the boot window; unknown = the tmux probe itself
35
- // failed (timed out under load) so death is UNPROVEN — render probe-failed, never offline/vanish.
36
- // reconcile composes the two into the compact DisplayStatus for one-glyph surfaces.
37
- // awaiting → the agent's PROPOSAL, awaiting a human:
38
- // proposal=merge → shown "review" ("ready, merge me")
39
- // proposal=nothing → shown "done" ("finished, your call")
40
- // proposal=close → shown "close-pending" ("I suggest discarding this worktree")
41
- // asking → the agent is pausing to ask the HUMAN a question. Written DETERMINISTICALLY two ways: the
42
- // mark-active PreToolUse hook captures it the moment the agent invokes the AskUserQuestion
43
- // tool (question → note), and the agent may also declare it via `spex session ask --note
44
- // <question>`. Not inferred. Distinct from `parked` (which waits on a background task/
45
- // schedule and self-resumes); an asking agent resumes only when a human sends it a prompt.
46
- // queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
47
- // (closed = the worktree AND the global record are removed; not a stored status)
48
- // The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible — nothing
49
- // auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (resume)
50
- // deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
51
- //
52
- // Launch rules: private `tmux -L <label>` socket + the session's pinned named-launcher command. The launcher
53
- // preserves its harness's normal permission model unless the user explicitly configured an automatic-permission
54
- // command. SPEXCODE_TMUX overrides the tmux socket for tests; no env var rewrites the launcher.
20
+ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js'
55
21
 
56
22
  const pexec = promisify(execFile)
57
23
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
58
- // the legacy/default harness for helpers and old records. New sessions derive their harness from the selected
59
- // launcher; all harness-specific launch facts still come from the adapter.
60
24
  const HARNESS = defaultHarness
61
25
  const COLS = 120, ROWS = 32
62
- // @@@ concurrency cap - the most working agents we let run AT ONCE. Heavy multi-agent load (many claude
63
- // processes computing simultaneously) was the source of resource-pressure crashes, so a launch beyond the
64
- // cap is QUEUED, not started: it becomes a durable `queued` worktree that the drainer launches the moment a
65
- // slot frees (an agent stops working/dies). NOT hardcoded — configured PER PROJECT in `spexcode.json`
66
- // (`sessions.maxActive`), so a box can be tuned to its capacity without touching the toolchain. Precedence:
67
- // spexcode.json → `SPEXCODE_MAX_ACTIVE` env → default 8. Read LIVE (cheap file read) so an edit takes effect
68
- // on the next drain tick, no restart. Floored at 1 so a bad value can't wedge the queue to 0.
69
26
  const DEFAULT_MAX_ACTIVE = 8
70
27
  function maxActive(): number {
71
28
  let v: number | undefined
@@ -104,23 +61,12 @@ const rvEnv = (id: string, harness = HARNESS) => {
104
61
  ...harness.launchEnv(id), ...homeVars].join(' ')
105
62
  }
106
63
 
107
- // the prompt-dispatch outcome type + its claude/codex delivery implementations live in the [[harness-adapter]]
108
- // (each harness OWNS its input channel — claude the rendezvous socket, codex app-server JSON-RPC). Re-exported here
109
- // for the existing importers (client.ts) that read it off the sessions module.
64
+ // Re-exported for existing importers.
110
65
  export type { DispatchResult }
111
66
 
112
67
  export type Lifecycle = SessionLifecycle
113
68
  export type Proposal = SessionProposal
114
- // `corrupt` and `retired` are the two RECORD-INTEGRITY readings — neither a lifecycle the agent authored nor a
115
- // liveness the runtime probed, but the honest answer when the record itself can no longer carry either: its
116
- // bytes don't parse, or the worktree it names is gone. They exist so such a row can never silently vanish.
117
69
  export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown' | 'corrupt' | 'retired'
118
- // liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
119
- // authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
120
- // override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
121
- // `unknown` = the liveness PROBE ITSELF failed (the tmux snapshot timed out / errored under load), so we
122
- // CANNOT tell — the row renders probe-failed, NEVER offline/closed and never vanishes (board honesty: a slow
123
- // box must not masquerade as a graveyard, the failure that drove the mass-restore incident).
124
70
  export type Liveness = 'online' | 'starting' | 'offline' | 'unknown'
125
71
  const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
126
72
 
@@ -139,15 +85,8 @@ export type Session = {
139
85
  sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
140
86
  }
141
87
 
142
- // ensure a session's GLOBAL store dir exists, returning its path. Idempotent (recursive mkdir) — every
143
- // writer that drops an artifact (record/prompt/launch/launch.sh/comms) calls this first so order never matters.
144
88
  function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
145
89
 
146
- // @@@ originating prompt - what the session was ASKED to do, captured at launch so a manager (human or
147
- // agent) can later answer "what was this session for?" WITHOUT transcript archaeology. Prompts are
148
- // multi-line, so they live as their own artifact (`prompt`) in the session's GLOBAL store dir (keyed by
149
- // session_id, [[state]]), never in the worktree. Everything here is BEST-EFFORT: a missing artifact (a
150
- // session launched before this existed) just means no prompt is shown — never an error, never blocks a launch.
151
90
  function writePromptFile(id: string, prompt: string): void {
152
91
  try { writeFileSync(join(storeDir(id), 'prompt'), prompt) } catch { /* best-effort; must never block the launch */ }
153
92
  }
@@ -159,11 +98,7 @@ function readPromptFile(id: string): string | null {
159
98
  return s.trim() ? s : null
160
99
  } catch { return null }
161
100
  }
162
- // @@@ deferred launch prompt - a QUEUED session is a fully-prepared worktree we have NOT launched claude
163
- // into yet. The exact prompt to launch it with — the directive-generated finish-the-op prompt, or the plain
164
- // human prompt — is parked as the `launch` artifact in the store dir so the drainer can launch it later
165
- // (possibly after a backend restart) WITHOUT re-deriving anything. CONSUMED (removed) the moment the session
166
- // launches, so it exists only while the session waits in the queue. Distinct from `prompt` (the originating ask).
101
+ // Persist queued launch input across restarts; consume it once the launch begins.
167
102
  function writeLaunchFile(id: string, prompt: string): void {
168
103
  try { writeFileSync(join(storeDir(id), 'launch'), prompt) } catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
169
104
  }
@@ -174,26 +109,18 @@ function removeLaunchFile(id: string): void {
174
109
  try { rmSync(sessionArtifactPath(id, 'launch'), { force: true }) } catch { /* best-effort */ }
175
110
  }
176
111
 
177
- // a one-line preview of the originating prompt for tables/events: first non-empty line, truncated.
178
- function promptPreview(prompt: string, n = 60): string {
179
- const first = prompt.split('\n').map((l) => l.trim()).find(Boolean) || ''
112
+ // One line, bounded the launch prompt's shape when it enters a compact headline.
113
+ export const HEADLINE_PREVIEW_COLUMNS = 60
114
+ function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
115
+ const first = text.split('\n').map((l) => l.trim()).find(Boolean) || ''
180
116
  return first.length > n ? first.slice(0, n - 1) + '…' : first
181
117
  }
182
118
 
183
- // @@@ session-label — the ONE place a session's display strings are derived ([[session-label]]). The raw
184
- // parts (a user rename `name`, the 7-word prompt truncation `title`) never leave this module at the top
185
- // level: toSession computes `label` (STABLE: name > node > title > branch > id — tables/selectors) and
186
- // `headline` (LIVE: name > activity > promptPreview > node > title > branch > id — what a human reads,
187
- // see [[session-activity]]) and the wire carries THOSE; the parts ride only under `raw` for the few
188
- // explicit consumers (the rename prefill). A surface that wants a session's name reads s.label/s.headline
189
- // — there is no bare s.title/s.name to reach for, which is the enforcement.
190
119
  export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
191
120
  r.name || r.node || r.title || r.branch || r.id
192
121
  export const deriveHeadline = (r: { name?: string | null; activity?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
193
122
  r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
194
123
 
195
- // accessors kept for the human-naming call sites (watch/notify/reply-channel): trivially the precomputed
196
- // wire fields, so every surface — CLI, dashboard, comms — reads the same derivation by construction.
197
124
  export const sessionLabel = (s: Session): string => s.label
198
125
  export const sessionHeadline = (s: Session): string => s.headline
199
126
 
@@ -217,30 +144,10 @@ function probeTimedOut(e: unknown): boolean {
217
144
  async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
218
145
  export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
219
146
 
220
- // worktrees + branches are created off MAIN even when the server runs inside a worktree.
221
- function mainRoot(): string {
222
- try {
223
- const checkout = dirname(gitCommonDir())
224
- const configured = readConfig(checkout).main?.trim()
225
- return configured ? resolve(checkout, configured) : checkout
226
- }
227
- catch { return repoRoot() }
228
- }
229
-
230
- // @@@ pkgRoot - the CLI package's OWN directory, derived from this module's location, never a hardcoded
231
- // repoRoot()+'spec-cli'. This file lives at <pkgRoot>/src/sessions.ts, so `..` from it is the package
232
- // root — making the launch-script paths (hooks/, node_modules/.bin/tsx, src/cli.ts) survive the package
233
- // being renamed or relocated out of the default <repo>/spec-cli layout.
234
147
  function pkgRoot(): string {
235
148
  return fileURLToPath(new URL('..', import.meta.url))
236
149
  }
237
150
 
238
- // the in-memory session record — the typed view of session.json. `governed` (dashboard-launched=true vs
239
- // user-self-launched=false), `worktreePath`/`branch`/`createdAt` are the fields the board USED to read off
240
- // the worktree (its path/birthtime); now they live IN the record, since the record is the enumeration source.
241
- // `name` is the rename override (distinct from the prompt-derived `title`); `session` is the harness session_id
242
- // (the store key). The launcher mints the id (`claude --session-id <id>`) so it equals what every hook payload
243
- // and CLAUDE_CODE_SESSION_ID carry — one id across the record dir, tmux window, rendezvous socket, and commits.
244
151
  export type SessRec = {
245
152
  session: string; governed: boolean; worktreePath: string; branch: string | null
246
153
  node: string | null; title: string | null; name: string | null
@@ -262,10 +169,6 @@ type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 's
262
169
  type LaunchReadinessPending = { version: 1; startedAt: number; original: LaunchReadinessOriginal }
263
170
  export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
264
171
 
265
- // @@@ stable launch authority - the supervisor injects its PUBLIC proxy URL into every replaceable child.
266
- // That URL survives child hot reload/restart; PORT inside a supervised child is private and ephemeral, so it
267
- // is only the fallback for a directly-run server with no injected API URL. Credentials/query/fragment are
268
- // not authority and may contain secrets, so they are stripped before the value reaches session.json.
269
172
  export function backendLaunchAuthority(env: { SPEXCODE_API_URL?: string; PORT?: string } = process.env): string {
270
173
  const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`
271
174
  const url = new URL(raw)
@@ -299,12 +202,6 @@ function readRecord(id: string): SessRec | null {
299
202
  `session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`)
300
203
  }
301
204
  }
302
- // @@@ SessionRecordUnusable - the record exists but cannot carry state, for one of two reasons, and BOTH must
303
- // stop a writer rather than let it invent one. `corrupt`: the bytes don't parse, so writing would DESTROY the
304
- // evidence of what broke and resurrect the session as a plausible-looking empty shell (the reported failure —
305
- // a damaged record came back as a valid `idle` record and even had a launch script regenerated for it).
306
- // `retired`: the work merged and the worktree the record names is gone, so there is nothing left to be active
307
- // IN. Readers that enumerate (the board) catch this and render the row; writers let it out, loud.
308
205
  export class SessionRecordUnusable extends Error {
309
206
  constructor(readonly code: 'corrupt' | 'retired', readonly session: string, message: string) {
310
207
  super(message)
@@ -313,16 +210,10 @@ export class SessionRecordUnusable extends Error {
313
210
  }
314
211
  const corruptReason = (e: { path: string; error: string }): string =>
315
212
  `session record is unreadable: ${e.path} — ${e.error}. The file is kept as-is; nothing will rewrite it. A close attempt quarantines the bytes and reports the preserved runtime/worktree/branch residue, but cannot signal or delete without an exact owner.`
316
- // a record whose worktree is gone names work that no longer exists on disk. That is the manual-retirement end
317
- // state (merged, worktree and branch removed, record left behind), and it is terminal: no lifecycle writer may
318
- // put such a session back to work, and no launch may be assembled for a directory that isn't there.
319
213
  function retirementReason(rec: SessRec): string | null {
320
214
  if (!rec.worktreePath || existsSync(rec.worktreePath)) return null
321
215
  return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`
322
216
  }
323
- // the read every LIFECYCLE writer uses: it additionally refuses a retired record. Metadata verbs (rename,
324
- // sort, archive) and `close` deliberately keep using readRecord — filing and removal stay available on a row
325
- // whose work is gone.
326
217
  function readLiveRecord(id: string): SessRec | null {
327
218
  const rec = readRecord(id)
328
219
  if (!rec) return null
@@ -503,27 +394,7 @@ function restoreLaunchReadinessOriginal(rec: SessRec): SessRec {
503
394
  const original = rec.launchReadinessPending?.original
504
395
  return original ? { ...rec, ...original, launchReadinessPending: null } : rec
505
396
  }
506
- // @@@ the ONE record writer - every field of session.json is produced HERE, by serializing the typed record,
507
- // and lands by atomic replace (temp file in the same dir, then rename). Nothing else — no hook, no shell, no
508
- // route — may compose or edit the file's text: a note is arbitrary human/agent prose, so any writer that
509
- // substitutes it into existing JSON eventually meets a quote, a backslash, or a newline and leaves a record
510
- // nothing can parse (the reported corruption came from exactly that: a hot-path hook editing the value with
511
- // sed). The shell hooks READ this file cheaply and delegate every WRITE back through the CLI to this function.
512
- // The rename is what makes a reader between two writes see one whole record instead of a truncated one.
513
- //
514
- // @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
515
- // present (nulls rendered as "" / the empty value, never an absent key). The stable shape is what lets the
516
- // pure-shell hooks answer "is this record already active, with nothing stale to clear?" with three exact-line
517
- // greps and no jq — a READ fast path, never an edit. So do NOT switch to conditional keys or a compact dump.
518
- //
519
- // @@@ the record self-cleans - the object below is a CLOSED key set rebuilt from the typed record on every
520
- // write, never a merge over what was read. So a field retired from the code is ALSO retired from disk the
521
- // next time anything touches that record: no migration verb, no GC pass, no accreting graveyard of dead keys.
522
- // A new field earns its place by being declared HERE and in `fromRaw` — that pairing is what keeps the file a
523
- // projection of the current type rather than a log of everything it has ever been, and it is also the reason
524
- // a field MISSING from this object is silently dropped: `stopped`, `archived`, `cold_proof`, and
525
- // `adapter_recovery` are distinct lifecycle/resource projection fields, so each must be listed and cleared by
526
- // its own transition rather than one being inferred from another.
397
+ // Rebuild the full disk projection so retired keys disappear on the next write.
527
398
  function writeRecord(rec: SessRec): void {
528
399
  let previous: SessRec | null = null
529
400
  try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
@@ -536,9 +407,6 @@ function writeRecord(rec: SessRec): void {
536
407
  title: rec.title ?? '',
537
408
  name: rec.name ?? '',
538
409
  parent: rec.parent ?? '',
539
- // A leased queue uses a raw token older drainers do not recognize as `queued`; current readers map it
540
- // back to the unchanged public lifecycle. This version fence is what keeps an orphaned old backend from
541
- // stealing the entry before it can even inspect the new launch_owner field.
542
410
  status: rawLifecycleStatus(rec),
543
411
  proposal: rec.proposal ?? '',
544
412
  merges: rec.merges,
@@ -576,11 +444,6 @@ function writeRecord(rec: SessRec): void {
576
444
  const tmp = join(dir, `.session.json.${process.pid}.tmp`)
577
445
  writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n')
578
446
  renameSync(tmp, path) // atomic within the dir: a concurrent reader sees the old record or the new one
579
- // session.json normally is the current public projection. A launch-readiness candidate is the sole internal
580
- // exception: its frozen original remains public until validation clears the fence. Persist each PUBLIC moved
581
- // lifecycle value before this writer returns, so a later write cannot erase a declaration note between
582
- // observer samples. New-record genesis stays with superviseTimeline; metadata-only writes do not manufacture
583
- // status events.
584
447
  const previousPublic = previous ? publicRecord(previous) : null
585
448
  const nextPublic = publicRecord(rec)
586
449
  if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
@@ -589,42 +452,10 @@ function writeRecord(rec: SessRec): void {
589
452
  }
590
453
  }
591
454
 
592
- // @@@ reconcile - the shown status. awaiting the proposal's label (review/done/close-pending),
593
- // shown regardless of liveness. active/idle → their LIVENESS: offline if no tmux for the recorded id OR
594
- // claude's rendezvous socket is gone (claude exited), else idle if the idle_prompt hook has fired since
595
- // the last tool use, else working.
596
-
597
- // @@@ liveTmux - which of OUR tmux sessions exist AND each pane's runtime probe, in TWO spawns total (one
598
- // tmux, one ps) for the WHOLE list. reconcile used to spawn two tmux per session (has-session +
599
- // display-message), so listing N sessions was 2N spawns — the dominant /api/sessions cost under multi-agent
600
- // load. `tmux list-sessions` returns every session on our socket at once; a session present here has a live
601
- // tmux window (session_name = the id we created it with), mapped to a PaneProbe: its pane's ROOT pid
602
- // (`#{pane_pid}`) plus ONE shared whole-box pid→(ppid, comm) table from a single `ps` spawn. tmux server
603
- // down / no sessions → empty map → everything reconciles to offline, which is correct. `live.has(id)` = the
604
- // window presence; `live.get(id)` = the probe, which the CODEX adapter's liveness walks to tell a running TUI
605
- // (a codex/node process among the pane pid's descendants) from a failed launch that dropped back to the bare
606
- // shell (see [[harness-adapter]] paneTreeRunsCodex — the pane's FOREGROUND command is `bash`, the launch
607
- // wrapper, even while the TUI renders, so the foreground name is NOT the signal). CLAUDE ignores the probe —
608
- // its workers launch through the `reclaude` wrapper, which runs claude as a CHILD, so claude liveness stays
609
- // its rendezvous socket. The per-session alive() above stays for the single-session ops (capture / rawKey).
610
- // (the whole-box ps snapshot itself — procSnapshot — lives in harness.ts beside its tree-walk consumers.)
611
- // @@@ LiveSnap - the ONE liveness snapshot the whole session list shares, built from a SINGLE tmux spawn
612
- // (`list-panes -a` yields every session's window presence, pane pid, AND pane title at once — every session has
613
- // ≥1 pane). `windows` = our live tmux windows (id → PaneProbe: pane pid + the hot-tier `pidAlive` verdict + the
614
- // legacy `procs` table when a pid-less codex session needs it); `titles` = each pane's RAW title (harness-
615
- // interpreted later in paneActivity — claude: a self-authored task summary; codex: a spinner + the cwd folder
616
- // name); `sockets` = the ids whose rendezvous socket has a LIVE LISTENER (connect-probed once here, not the
617
- // file-exists lie — [[harness-adapter]]); `unproven` = the ids whose LISTENER probe could not conclude (timeout
618
- // under load / EAGAIN off a full-but-alive backlog — see rendezvousListening's tri-state) — death UNPROVEN, so
619
- // those rows read `unknown`, never `offline`; `probeFailed` = the tmux probe itself FAILED (timed out under
620
- // load), DISTINCT from "tmux up, no sessions" — the former means death is UNPROVEN so those rows read `unknown`,
621
- // the latter is authoritative and reads `offline`.
455
+ // Share one liveness snapshot rather than spawning tmux for every displayed session.
622
456
  export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
623
457
 
624
- // @@@ parseLivePanes - the pure parser for the SINGLE merged `list-panes -a -F
625
- // '#{session_name}\t#{pane_pid}\t#{pane_title}'` snapshot: id → { panePid, title }. First pane per session wins
626
- // (our sessions are single-pane by construction). The title is the remainder AFTER the 2nd tab, so a title that
627
- // itself contains tabs survives intact. Exported so the parse is unit-auditable without a tmux spawn.
458
+ // First pane per session wins; split only twice so titles may contain tabs.
628
459
  export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
629
460
  const m = new Map<string, { panePid?: number; title?: string }>()
630
461
  for (const line of out.split('\n')) {
@@ -642,15 +473,7 @@ export function parseLivePanes(out: string): Map<string, { panePid?: number; tit
642
473
  return m
643
474
  }
644
475
 
645
- // @@@ agent.pid hot registry - the per-session death-latch backing BOTH hotSignature (the 100ms tier) and
646
- // liveSnapshot's codex `pidAlive` verdict, so ONE latch rule serves both. keyed by session id → the last
647
- // agent.pid { mtime, pid, deadLatched }. agentAlive statSyncs the pid file: missing → undefined (a
648
- // pre-registration/old session — the warm tier / legacy tree-walk covers it); mtime changed → a relaunch wrote
649
- // a fresh pid, so re-read + RESET the latch; then kill-0 the pid — alive (or EPERM) = true, ESRCH = false and
650
- // LATCH permanently for this (pid, mtime) so a later pid-reuse by an unrelated process can never resurrect a
651
- // dead session (the portable pid-reuse guard: death is irreversible per registration; only a NEW agent.pid
652
- // write — a fresh mtime — resets). Sync fs + one kill-0 syscall, NO child process, so it stays honest under a
653
- // thrashed event loop — exactly when a spawn-based probe would hang.
476
+ // Latch ESRCH per pid-file mtime so a recycled OS PID cannot revive an old session.
654
477
  type PidEntry = { mtimeMs: number; pid: number; deadLatched: boolean }
655
478
  const pidRegistry = new Map<string, PidEntry>()
656
479
  function readAgentPid(p: string): number { try { return Number(readFileSync(p, 'utf8').trim()) } catch { return NaN } }
@@ -670,10 +493,7 @@ function agentAlive(id: string): boolean | undefined {
670
493
  }
671
494
  }
672
495
 
673
- // @@@ needsCodexProcScan - the legacy ps-scan GATE, factored PURE so it is assertable. The whole-box `ps`
674
- // (procSnapshot) is paid ONLY when a windowed CODEX session has NO registered agent.pid (a pre-registration
675
- // launch that still needs the paneTreeRunsCodex tree-walk). A box with no codex, or all pid-registered
676
- // launches, returns false → zero ps spawn. Self-extinguishes as pre-registration sessions close.
496
+ // Only pre-agent.pid Codex sessions need the legacy whole-process scan.
677
497
  export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean }[]): boolean {
678
498
  return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
679
499
  }
@@ -722,12 +542,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
722
542
  return { probeFailed: false, windows, titles, sockets, unproven }
723
543
  }
724
544
 
725
- // @@@ hotSignature - the 100ms zero-spawn death detector ([[state]] hot tier). NO child processes, NO async
726
- // socket connects — sync fs + one kill-0 syscall per session, so it stays honest under a thrashed event loop
727
- // (exactly when a spawn-based probe would hang). The id list is refreshed LAZILY (at most once/second) from
728
- // listSessionIds; per call each id's verdict comes from agentAlive (the shared death-latch registry). A session
729
- // with NO agent.pid (pre-registration/old) is SKIPPED here — the warm tier covers it. The fingerprint is the
730
- // sorted `${id}:${alive?1:0}` pairs plus the id set, so it moves the instant a registered agent dies.
545
+ // Avoid process spawns on the hot path; old sessions without agent.pid remain warm-tier only.
731
546
  let hotIds: string[] = []
732
547
  let hotIdsAt = 0
733
548
  export async function hotSignature(): Promise<string> {
@@ -747,10 +562,7 @@ export async function hotSignature(): Promise<string> {
747
562
  return pairs.sort().join(',') + '|' + present.sort().join(',')
748
563
  }
749
564
 
750
- // @@@ warmSignature - the 1s tier ([[state]] warm tier): the SINGLE merged tmux snapshot (windows + pane pids +
751
- // titles) plus the rendezvous listener tri-state, fingerprinted so a socket dying (claude exit), the probe
752
- // flipping to unknown, a listener wedging (unproven), or a headline changing pushes a board-changed the instant
753
- // it happens — not on window churn alone. Sorted so it only moves on a real change; NO git, NO extra store walk.
565
+ // Include listener and title changes so watchers refresh without another store read.
754
566
  export async function warmSignature(): Promise<string> {
755
567
  const snap = await liveSnapshot()
756
568
  return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
@@ -805,18 +617,6 @@ export const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans lau
805
617
  const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
806
618
  // exit before readiness is retryable, but it is not proof of one specific cause
807
619
 
808
- // @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
809
- // lifecycle, from a prebuilt runtime snapshot (no per-call spawn — see liveSnapshot) + the adapter's own channel
810
- // check. Order of honesty: if the PROBE ITSELF failed (tmux timed out under load) death is UNPROVEN → `unknown`
811
- // (render probe-failed, NEVER a false offline that empties the board and provokes a mass-restore). Else offline
812
- // iff the tmux window is gone OR the adapter's online-signal is absent past the boot window. claude (via the
813
- // reclaude wrapper) holds CLAUDE_BG_RENDEZVOUS_SOCK open the whole time it is alive, so a LIVE LISTENER on that
814
- // socket (`snap.sockets`, connect-probed — NOT the socket FILE, which a crash leaves behind) is the truth —
815
- // not the pane, whose foreground is the wrapper/shell while claude runs as its child. codex has no such socket,
816
- // so its truth is the pane's DESCENDANT PROCESS TREE from the SAME snapshot: a live TUI keeps a codex/node
817
- // process below the pane pid; a failed launch leaves the pane at a bare shell, even while the shared app-server
818
- // sock lingers. A just-launched agent whose online-signal hasn't appeared yet reads the transient 'starting'
819
- // for the grace window; only past it (still not online) is it genuinely 'offline'.
820
620
  export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
821
621
  if (!rec.session || rec.stopped || rec.archived) return 'offline'
822
622
  // Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
@@ -842,12 +642,6 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
842
642
  return 'offline'
843
643
  }
844
644
 
845
- // reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
846
- // surfaces ([[state]]), never a third source of truth. Lifecycle wins the label except where liveness must
847
- // show through: awaiting → its proposal label; parked/error/asking/queued → themselves; active/idle → their
848
- // liveness (offline/starting/unknown), else the active-only idle/working inference (the mark-active hook flips
849
- // idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
850
- // terminal-mount and the relaunch panel on; this label is for badges and `spex session ls`.
851
645
  function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
852
646
  // record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
853
647
  // about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
@@ -869,10 +663,6 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
869
663
  return { path: rec.worktreePath, branch: rec.branch, rec }
870
664
  }
871
665
 
872
- // @@@ corruptSession - the row for a record we cannot parse. Every display field the surfaces read is filled
873
- // from the ONE thing we still know (the id) plus the diagnosis, so the row renders everywhere without any
874
- // surface having to special-case a half-record. Liveness is `unknown`, not `offline`: we never probed, so we
875
- // have not proven anything about the agent — the same honesty rule a failed probe follows.
876
666
  function corruptSession(id: string, entry: { path: string; error: string }): Session {
877
667
  const label = `${id.slice(0, 8)} (unreadable record)`
878
668
  return {
@@ -890,38 +680,28 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
890
680
  // dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
891
681
  const showActivity = lv === 'online'
892
682
  const act = showActivity ? activity : null
893
- const pp = prompt ? promptPreview(prompt) : null
683
+ const pp = prompt ? oneLinePreview(prompt) : null
894
684
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
895
685
  const harness = harnessById(rec.harness || defaultHarness.id)
896
686
  return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
897
687
  }
898
688
 
899
- // @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
900
- // over the derived label (node/title/branch/id) on every surface. Persisted to the session's global
901
- // record (`session.json` in the store, like every other field) so the name survives backend restarts
902
- // and is read back like any other field. A blank name CLEARS the override, reverting the row to its derived label. Works for a session in
903
- // any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
904
- // (the route answers 404). The frontend's right-click rename is the sole caller today.
905
689
  export async function renameSession(id: string, name: string): Promise<boolean> {
906
- return runSessionOperation({ op: 'rename', sessionId: id }, () => withRecordLock(id, async () => {
690
+ return withRecordLock(id, async () => {
907
691
  const wt = await findWorktree(id)
908
692
  if (!wt) return false
909
693
  writeRecord({ ...wt.rec, name: name.trim() || null })
910
694
  return true
911
- }))
695
+ })
912
696
  }
913
697
 
914
- // @@@ setSessionSort - set (or clear) a session's drag-reorder pseudo-time ([[session-reorder]]), parallel
915
- // to renameSession: persisted to the session's global record so the manual order survives restarts and
916
- // shows on every surface (all sort by `sortKey ?? created`). A null key CLEARS it, dropping the row back to
917
- // its `created` slot. Works in any state since it edits the on-disk record. Unknown id → false (route 404s).
918
698
  export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
919
- return runSessionOperation({ op: 'sort', sessionId: id }, () => withRecordLock(id, async () => {
699
+ return withRecordLock(id, async () => {
920
700
  const wt = await findWorktree(id)
921
701
  if (!wt) return false
922
702
  writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
923
703
  return true
924
- }))
704
+ })
925
705
  }
926
706
 
927
707
  // the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded. A record we
@@ -932,17 +712,9 @@ export async function sessionPrompt(id: string): Promise<string | null> {
932
712
  catch (e) { if (e instanceof SessionRecordUnusable) return null; throw e }
933
713
  }
934
714
 
935
- // @@@ lastKnownSession - the last successfully-read Session row per session_id. The record's EXISTENCE in
936
- // the store is definitive; a transient failure reading it (an ENOENT race, or a sibling read failing under a
937
- // concurrent merge) must NOT drop the row from the board — that absence is exactly what watchSessions used to
938
- // mis-read as a `closed · removed`. So a degraded read serves this last-known row instead of vanishing. Pruned
939
- // each poll to only ids still present.
715
+ // Preserve rows through a transient record-read failure; prune after the store entry disappears.
940
716
  const lastKnownSession = new Map<string, Session>()
941
717
 
942
- // @@@ listSessions - the board's session list, enumerated from the GLOBAL per-session store (replacing the
943
- // old `git worktree list` scan). Every GOVERNED record this project owns becomes a row, status reconciled;
944
- // non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
945
- // Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
946
718
  export async function listSessions(includeArchived = false): Promise<Session[]> {
947
719
  // ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
948
720
  // every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
@@ -1032,17 +804,12 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
1032
804
  }, () => {
1033
805
  // DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
1034
806
  // session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
1035
- // show yet, it reappears next poll — and since it was never in watchSessions' `prev`, no false closed.)
807
+ // show yet, and it reappears on the next build.)
1036
808
  return lastKnownSession.get(id) ?? null
1037
809
  }))
1038
810
  // prune last-known entries for ids that no longer appear at all (genuinely removed), keeping it bounded.
1039
811
  const liveIds = new Set(ids)
1040
812
  for (const k of [...lastKnownSession.keys()]) if (!liveIds.has(k)) lastKnownSession.delete(k)
1041
- // @@@ creation order - order by birth (oldest first): each session keeps its slot for life and a new one
1042
- // simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex session ls`).
1043
- // `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
1044
- // overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
1045
- // same-instant births (or sort-keys) stay deterministic.
1046
813
  return rows.filter((s): s is Session => s != null && (includeArchived || !s.archived))
1047
814
  .sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
1048
815
  }
@@ -1055,118 +822,6 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
1055
822
  catch { return existsSync(sessionStoreDir(id)) ? degraded() : null }
1056
823
  }
1057
824
 
1058
- // @@@ session graph = LIVE monitors, not a stored relationship. An edge A→B means "agent A is RIGHT NOW
1059
- // running `spex session watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
1060
- // persisted subscription. When a `spex session watch` process starts it registers here and heartbeats; the edge
1061
- // exists ONLY while that watch runs (deregistered on exit, dropped on a missed heartbeat). Single owner:
1062
- // this in-memory map in the SERVER process — the watch process (a separate `spex session watch`) talks to it over
1063
- // HTTP (POST /api/sessions/edges/watch + …/unwatch). No datastore, no file: a backend restart starts
1064
- // empty and live watches re-register on their next heartbeat. Kept isolated from the board assembler.
1065
- // an edge is either a LIVE monitor arrow (A→B = A watches B, directed) or a recorded comms link (A↔B =
1066
- // they have exchanged `count` direct messages, undirected). The dashboard renders the two kinds apart.
1067
- export type Edge = { from: string; to: string; kind: 'monitor' | 'comms'; count?: number }
1068
-
1069
- // @@@ comms log - direct agent talk ([[comms-edge]]), recorded per-worktree. `spex session send` goes
1070
- // THROUGH the backend (sendText); on a delivered message that carries a sender, the backend appends one
1071
- // {peer, ts} line to the RECIPIENT's comms log — each message counted exactly once, on the side the backend
1072
- // already resolved. Persisted (survives a backend restart, unlike the in-memory monitor registrations) and
1073
- // untracked, in the session's GLOBAL store dir (`comms.ndjson`, keyed by session_id) — it dies with the
1074
- // session record, matching a graph of LIVE sessions. No sender → not logged. Best-effort: a recording failure
1075
- // must NEVER fail the delivered message.
1076
- function commsLog(id: string): string { return sessionArtifactPath(id, 'comms.ndjson') }
1077
- async function recordComms(toId: string, fromId: string): Promise<void> {
1078
- if (!fromId || fromId === toId) return
1079
- try {
1080
- if (!readRecord(toId)) return
1081
- appendFileSync(join(storeDir(toId), 'comms.ndjson'), JSON.stringify({ peer: fromId, ts: new Date().toISOString() }) + '\n')
1082
- } catch { /* a recording failure must not fail the delivered send */ }
1083
- }
1084
- // the peers this session has exchanged messages with — one entry per message, newest appended last.
1085
- function readComms(id: string): string[] {
1086
- try {
1087
- const path = commsLog(id)
1088
- if (!existsSync(path)) return []
1089
- return readFileSync(path, 'utf8').split('\n').filter(Boolean)
1090
- .map((l) => { try { return String(JSON.parse(l).peer || '') } catch { return '' } }).filter(Boolean)
1091
- } catch { return [] }
1092
- }
1093
- // keyed by an opaque per-watch token (one per `spex session watch` process), so a single agent may run several
1094
- // monitors without them clobbering each other. `selectors` is what the watch targets (resolved LIVE at
1095
- // read time, not frozen here); empty / @all = a GLOBAL watcher. `expires` is the heartbeat backstop.
1096
- type WatchReg = { watcher: string; selectors: string[]; expires: number }
1097
- const watches = new Map<string, WatchReg>()
1098
- const DEFAULT_WATCH_TTL_MS = 15000
1099
- // register OR heartbeat a live monitor. watcher = the watching agent's OWN session id; ttlMs = how long
1100
- // this stays live without another beat. Returns false on a bad pair (the route answers 400).
1101
- export function registerWatch(token: string, watcher: string, selectors: string[], ttlMs = DEFAULT_WATCH_TTL_MS): boolean {
1102
- if (!token || !watcher) return false
1103
- watches.set(token, { watcher, selectors: selectors.filter(Boolean), expires: Date.now() + Math.max(1000, ttlMs) })
1104
- return true
1105
- }
1106
- // deregister a watch (its `spex session watch` exited); false if the token wasn't registered.
1107
- export function deregisterWatch(token: string): boolean { return watches.delete(token) }
1108
- // the still-live registrations, pruning any whose heartbeat lapsed — the backstop for a watch that died
1109
- // without a clean unwatch (SIGKILL, a dropped connection, a backend that was down at exit time).
1110
- function liveWatches(): WatchReg[] {
1111
- const now = Date.now()
1112
- const out: WatchReg[] = []
1113
- for (const [token, reg] of watches) {
1114
- if (reg.expires <= now) watches.delete(token)
1115
- else out.push(reg)
1116
- }
1117
- return out
1118
- }
1119
- // the graph: live sessions as nodes; edges DERIVED from live monitor registrations. Edge A→B = watcher A
1120
- // is currently watching B. Selectors are resolved LIVE here via selectSessions (the same matcher `spex
1121
- // ls/watch` use), so a global (@all/empty) watcher links to every CURRENT session — incl. ones launched
1122
- // after the watch started — and a node/branch selector picks up future matches too. Self-edges and edges
1123
- // touching a non-live session are dropped; duplicate A→B (two watches over the same pair) collapse to one.
1124
- export async function sessionGraph(): Promise<{ nodes: Session[]; edges: Edge[] }> {
1125
- const nodes = await listSessions()
1126
- const live = new Set(nodes.map((s) => s.id))
1127
- const edges: Edge[] = []
1128
- const seen = new Set<string>()
1129
- for (const reg of liveWatches()) {
1130
- if (!live.has(reg.watcher)) continue // the watching agent itself is gone
1131
- for (const t of selectSessions(nodes, reg.selectors)) {
1132
- if (t.id === reg.watcher) continue
1133
- const key = `${reg.watcher} ${t.id}`
1134
- if (seen.has(key)) continue
1135
- seen.add(key)
1136
- edges.push({ from: reg.watcher, to: t.id, kind: 'monitor' })
1137
- }
1138
- }
1139
- // comms edges: undirected direct-talk, one per pair, carrying the message count — read from each live
1140
- // session's per-worktree log and aggregated by sorted pair so A→B and B→A fold into one A↔B count. An
1141
- // edge to a non-live session is dropped, like the monitor edges.
1142
- const commsCount = new Map<string, number>()
1143
- for (const n of nodes) {
1144
- for (const peer of readComms(n.id)) {
1145
- if (peer === n.id || !live.has(peer)) continue
1146
- const key = n.id < peer ? `${n.id}\t${peer}` : `${peer}\t${n.id}`
1147
- commsCount.set(key, (commsCount.get(key) ?? 0) + 1)
1148
- }
1149
- }
1150
- for (const [key, count] of commsCount) {
1151
- const [from, to] = key.split('\t')
1152
- edges.push({ from, to, kind: 'comms', count })
1153
- }
1154
- return { nodes, edges }
1155
- }
1156
-
1157
- // @@@ apiBase resolution - WHICH backend a client verb talks to, resolved ONCE per process with the
1158
- // source kept as a discriminant ([[remote-client]]). The core thesis: a FLAG is the only signal that is
1159
- // provably deliberate — an env var cannot tell "I exported this on the command" from "I inherited this
1160
- // from the backend that launched my shell", and that ambiguity is exactly the misroute bug (a shell
1161
- // carrying project A's SPEXCODE_API_URL silently drives every bare `spex` in project B at A's backend).
1162
- // The ladder:
1163
- // 1. explicit `--api <url>` (`--port <n>` = localhost sugar) — always wins, any verb
1164
- // 2a. WORKER (SPEXCODE_SESSION_ID present): env SPEXCODE_API_URL — the backend-injected lifeline; a
1165
- // worker's state writes must NEVER gamble on cwd discovery, so the env is not demotable by (2b)
1166
- // 2b. HUMAN (no session id): the cwd project's RECORDED live backend (`spex serve` writes it, we
1167
- // /health-probe before trusting — a dead record is ignored, never followed)
1168
- // 3. the other side as fallback (human with no live record → env; worker with no env → record)
1169
- // 4. default http://127.0.0.1:$PORT||8787
1170
825
  export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
1171
826
  export type ApiBaseInfo = { url: string; source: ApiBaseSource }
1172
827
  const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
@@ -1224,107 +879,27 @@ let apiBaseMemo: Promise<ApiBaseInfo> | null = null
1224
879
  export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
1225
880
  export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
1226
881
 
1227
- // @@@ watch registration (CLIENT side) - a `spex session watch` process is separate from the server, so it
1228
- // REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
1229
- // deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
1230
- // streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
1231
- // the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
1232
- // Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
1233
- // re-exported here so callers keep one name. Used by `spex session watch` + the agent-typed `spex session …`
1234
- // declarations; the hooks instead pass `--session <id>` from the payload, so they never depend on this.
1235
882
  export const ownSessionId = envSessionId
1236
883
 
1237
- // @@@ withSenderHint - bidirectional agent messaging. `spex session send` delivers a prompt to the
1238
- // recipient; this stamps WHO sent it and HOW to reply as a one-line insert appended to the delivered
1239
- // message, so the recipient agent CAN reply (or ignore) and the reply rides the SAME send back into the
1240
- // sender's prompt — a reply channel, no workflow enforcement, just a prompt insert. The sender is the
1241
- // SENDING agent's OWN session (id from [[dispatch]]'s send-command process via ownSessionId, `label` its
1242
- // board HEADLINE — sessionHeadline, the same title the recipient reads on the board); its FULL id is stamped
1243
- // so the reply addresses exactly one session, never a prefix. A human running `send` from a plain shell has
1244
- // no session id (sender=null) → the bare message, no hint, no loop.
1245
- // @@@ delimited as a SESSION TITLE - the headline is wrapped `session "<headline>" (<id>)` so the recipient
1246
- // reads it AS a session title, not as prose bleeding into the message (an un-delimited prompt-derived title
1247
- // was unrecognisable as a name). A bare-id label (no better name in the chain) needs no quotes.
1248
884
  export type MsgSender = { id: string; label: string | null }
1249
885
  export function withSenderHint(text: string, sender: MsgSender | null): string {
1250
886
  if (!sender) return text
1251
887
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
1252
888
  return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
1253
889
  }
1254
- // @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
1255
- // terminal can return text to its human only through its declaration NOTE ([[session-timeline]]). This
1256
- // insert tells the agent exactly that, so its next stop carries the complete answer in `--note` instead of
1257
- // prose that dies in an unseen output stream. The declaration is TRANSPORT, not task work: a raw prompt's
1258
- // "no tools" / "only print" constraint does not cancel it. composeSessionPrompt is the only production caller
1259
- // deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
1260
- // default. The notice declares itself
1261
- // PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
1262
- // note-replied a few times keeps note-replying from context inertia long after the human is back at a
1263
- // terminal — the sticky-note failure this pair exists to prevent.
1264
890
  export const withNoteReplyHint = (text: string): string =>
1265
891
  `${text}\n\n— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For a simple answer awaiting the next message, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`
1266
- // @@@ withTerminalReplyHint - the BACK-AT-A-TERMINAL counter-insert, appended exactly once at the
1267
- // note→terminal transition (a human send with no note flag whose previous human send carried one —
1268
- // [[session-timeline]] lastHumanSendVia). It explicitly countermands the note-reply instruction: telling
1269
- // the agent once beats hoping it scopes the earlier per-message notice correctly (it demonstrably doesn't —
1270
- // codex especially). Not repeated on later terminal sends: once the transition send is recorded, the last
1271
- // human channel is no longer note, so ordinary conversation stays insert-free.
1272
892
  export const withTerminalReplyHint = (text: string): string =>
1273
893
  `${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`
1274
- async function postJSON(path: string, body: unknown): Promise<void> {
1275
- try {
1276
- await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
1277
- } catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
1278
- }
1279
- export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
1280
- postJSON('/api/sessions/edges/watch', { token, watcher, selectors, ttlMs })
1281
- export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/edges/unwatch', { token })
1282
-
1283
- // @@@ isBackendDown - a `client.ts` BackendError surfacing in the watch poll loop (whose session
1284
- // `source` is the HTTP backend client). Matched by NAME, not `instanceof`, so sessions.ts never imports
1285
- // client.ts at runtime (client.ts imports apiBase from here — a runtime import back would be a cycle). A
1286
- // backend-down poll must NOT be swallowed as a transient git/tmux hiccup: watch warns ONCE and keeps
1287
- // streaming rather than emitting false `closed` events for every session.
1288
- export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.name === 'BackendError'
1289
- // @@@ isBackendUnreachable - the TRANSIENT subset of isBackendDown: the fetch itself failed (nothing
1290
- // listening — ECONNREFUSED / "fetch failed"), which client.ts throws as a BackendError with NO HTTP
1291
- // `status`. An HTTP BackendError (the backend answered non-2xx) DOES carry a status and is a real error, not
1292
- // a momentary blip. The distinction matters to `spex session wait`: a supervisor's backgrounded wait must survive
1293
- // the ~1s window where the supervisor reboots its hot-reloaded child behind the stable port, retrying until
1294
- // the backend answers again or the deadline hits — never dying on the in-flight fetch that a sibling merge's
1295
- // restart happens to interrupt. Read via a structural cast (no client.ts import — that would be a cycle).
1296
- export const isBackendUnreachable = (e: unknown): boolean =>
1297
- isBackendDown(e) && (e as { status?: number }).status === undefined
1298
-
1299
- // @@@ slugify - the branch/worktree-safe slug. Keeps ANY unicode letter/number (git refs and the filesystem
1300
- // take unicode), so a CJK prompt survives as the readable name its author typed instead of being stripped to
1301
- // nothing — transliteration would buy ASCII at the cost of a dependency and a name nobody wrote. NFC pins one
1302
- // canonical byte form across IME/OS variants. Non-empty is guaranteed by the 'session' fallback; uniqueness
1303
- // is the caller's job (the create transaction suffixes the session short-id).
1304
894
  export const slugify = (s: string | null) =>
1305
895
  (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
1306
896
 
1307
- // @@@ node + title from the prompt - the spec node a session works on is the FIRST `[[<id>]]` topic
1308
- // reference in the raw prompt ([[mentions]]: `[[node]]` is a topic, `@` is an actor/session). The node the
1309
- // user actually left in the prompt is the truth: there is no focused-node/API/function argument that can
1310
- // grant a binding outside the task text. Changing or deleting the mention changes or removes the binding.
1311
- // When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
1312
- // The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
1313
- // (`.plugins`) keeps the dot — without `\.?` here `[[.plugins]]` captures nothing and never resolves to a node.
1314
- // Token chars are ANY unicode letter/number (slugify's already-made choice): a CJK dir name is a legal node
1315
- // id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
1316
- // node-agnostic.
1317
897
  const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
1318
898
  export const nodeFromPrompt = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
1319
899
 
1320
900
  type CommandPreset = Pick<ConfigPreset, 'name' | 'body'>
1321
901
  type CommandSpec = Pick<SpecLite, 'id' | 'path'>
1322
902
 
1323
- // @@@ command invocation - turn the raw `/<preset> [[node]]… free text` into the ONE agent prompt.
1324
- // This is deliberately server-side: dashboard, phone, CLI, direct API, and the in-process fallback all call
1325
- // the launch/send boundary, so no client gets its own command interpreter. Launch keeps the RAW prompt for
1326
- // session identity/history; only the agent payload uses this expansion, preventing a plugin body's own
1327
- // [[links]] from becoming the session node. With no mention, a preset remains targetless.
1328
903
  export function composeCommandPrompt(raw: string, presets: CommandPreset[], specs: CommandSpec[]): string {
1329
904
  const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/)
1330
905
  if (!match) return raw
@@ -1378,12 +953,9 @@ export async function composeSessionPrompt(raw: string, target: SessionPromptTar
1378
953
  const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
1379
954
  const text = replyVia === 'note' ? withNoteReplyHint(prompt)
1380
955
  : !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
1381
- return { text, ...(replyVia ? { replyVia } : {}) }
956
+ return { text: optionSafe(text), ...(replyVia ? { replyVia } : {}) }
1382
957
  }
1383
- // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
1384
- // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
1385
- // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
1386
- // it from under itself. Strip both before deriving; whatever prose remains names the session.
958
+ const optionSafe = (text: string) => text.startsWith('-') ? ` ${text}` : text
1387
959
  const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
1388
960
  const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
1389
961
  export function titleFromPrompt(prompt: string): string | null {
@@ -1447,8 +1019,7 @@ export function launchPreflight(rec: SessRec): LaunchBlock | null {
1447
1019
 
1448
1020
  // @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
1449
1021
  // invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
1450
- const shq1 = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
1451
- export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): string {
1022
+ export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
1452
1023
  const file = join(storeDir(id), 'launch.sh')
1453
1024
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
1454
1025
  // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
@@ -1461,11 +1032,11 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1461
1032
  // agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
1462
1033
  // (claude: env→(reclaude→)claude; codex: env→bash -lc <script> whose last line is `exec codex … resume`), and
1463
1034
  // `$$` therefore IS the launched agent's pid. `env` carries the leading `VAR=val` assignments (an env prefix
1464
- // can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shq1) so the
1035
+ // can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shQuote) so the
1465
1036
  // invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
1466
1037
  // parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
1467
1038
  const pidPath = join(storeDir(id), 'agent.pid')
1468
- const born = `sh -c ${shq1(`printf %s "$$" > ${shq1(pidPath)}; exec env ${invocation}`)}`
1039
+ const born = `sh -c ${shQuote(`printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`
1469
1040
  // Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
1470
1041
  // ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
1471
1042
  // has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
@@ -1508,7 +1079,7 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1508
1079
  // -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
1509
1080
  // can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
1510
1081
  // bounded retry stands.
1511
- ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shq1(fatal)}; then`,
1082
+ ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
1512
1083
  ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
1513
1084
  ` exit $__spex_rc`,
1514
1085
  ` fi`,
@@ -1519,36 +1090,21 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1519
1090
  `exit $__spex_rc`,
1520
1091
  ``,
1521
1092
  ]
1522
- const delegatePreamble = delegateFifo ? [
1523
- `exec 9<${shq1(delegateFifo)}`,
1524
- `rm -f ${shq1(delegateFifo)}`,
1525
- 'export SPEXCODE_MAINTENANCE_DELEGATE_FD=9',
1526
- `export SPEXCODE_MAINTENANCE_SESSION_ID=${shq1(id)}`,
1527
- ] : []
1528
- writeFileSync(file, [...delegatePreamble, ...launchBody].join('\n'))
1093
+ writeFileSync(file, launchBody.join('\n'))
1529
1094
  return file
1530
1095
  }
1531
- async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): Promise<void> {
1096
+ async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
1532
1097
  // record the transport path THIS runtime hands the agent, before anything reads it (launchScript bakes it
1533
1098
  // into the launch env). Same kind of launch-time fact as agent.pid, and the reason a session's socket is
1534
1099
  // reachable only from the world it belongs to ([[harness-adapter]] rendezvous socket).
1535
1100
  if (harness.ownsRendezvous) stampRvSock(id)
1536
1101
  await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path])
1537
- await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd, delegateFifo)}`])
1102
+ await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
1538
1103
  await tmux(['send-keys', '-t', id, 'Enter'])
1539
1104
  launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
1540
1105
  }
1541
1106
 
1542
1107
 
1543
- // @@@ concurrency cap + queue - keep at most maxActive() agents AUTONOMOUSLY PROGRESSING at once. A slot is
1544
- // COMPUTE pressure, so only an agent actually consuming it holds one: genuinely live (tmux window + rendezvous
1545
- // socket present) AND either churning (`working`) or paused-to-self-resume (`parked`). Every state that is
1546
- // WAITING ON THE HUMAN frees its slot — `idle` (stopped at its prompt), `asking` (asked a question), and the
1547
- // proposal states (review/done/close-pending) — exactly as `offline`/`queued` do. Those agents burn no
1548
- // compute, so they must NEVER block a fresh launch: the old rule counted them, so a pile of "waiting on you"
1549
- // sessions wedged the queue while the box sat near-idle (the reported blockage). Liveness is still checked
1550
- // directly (the socket truth reconcile uses), so an authored `parked` whose claude has since died does NOT
1551
- // pin a slot. The cap throttles concurrent COMPUTE; everything waiting-on-you waits cheap as a live pane.
1552
1108
  const OCCUPIES_SLOT = new Set<DisplayStatus>(['working', 'parked', 'starting']) // starting's boot window is also held via `launching`
1553
1109
  function isOccupying(s: Session, snap: LiveSnap): boolean {
1554
1110
  if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
@@ -1606,13 +1162,17 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
1606
1162
  launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
1607
1163
  const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
1608
1164
  try {
1609
- const sq = `'${launchPrompt.replace(/'/g, `'\\''`)}'`
1165
+ const sq = shQuote(launchPrompt)
1610
1166
  await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec))
1611
1167
  } catch {
1612
1168
  launching.delete(id)
1613
1169
  return false // launch failed → stays `queued`, retried on the next drain tick
1614
1170
  }
1615
- writeRecord({ ...wt.rec, status: 'active', proposal: null, launchOwner: null })
1171
+ // the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
1172
+ // launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
1173
+ // belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
1174
+ // headline precedence stands on.
1175
+ writeRecord({ ...wt.rec, status: 'active', proposal: null, note: null, launchOwner: null })
1616
1176
  removeLaunchFile(id) // consumed
1617
1177
  // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
1618
1178
  // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
@@ -1621,11 +1181,6 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
1621
1181
  }
1622
1182
  const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
1623
1183
 
1624
- // @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
1625
- // re-entrancy-guarded; safe to call on every slot-freeing event (session creation / close / propose) AND on a
1626
- // periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
1627
- // (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
1628
- // so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
1629
1184
  async function drainQueueUnlocked(): Promise<void> {
1630
1185
  if (draining) return
1631
1186
  draining = true
@@ -1651,20 +1206,13 @@ async function drainQueueUnlocked(): Promise<void> {
1651
1206
  }
1652
1207
  } finally { draining = false }
1653
1208
  }
1654
- export const drainQueue = (): Promise<void> => runSessionOperation({ op: 'queue-drain' }, drainQueueUnlocked)
1209
+ export const drainQueue = (): Promise<void> => drainQueueUnlocked()
1655
1210
  const requestQueueDrain = (): void => {
1656
1211
  void drainQueue().catch((error) => {
1657
- // An exact maintenance stop deliberately frees no unrelated queue work while admission is closed.
1658
- if (error instanceof SessionMaintenanceError && error.code === 'maintenance_active') return
1659
1212
  console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`)
1660
1213
  })
1661
1214
  }
1662
1215
 
1663
- // @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
1664
- // session creation/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
1665
- // going parked writes its global session.json record from a hook subprocess the server never sees, and a crash just makes a
1666
- // socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
1667
- // and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
1668
1216
  let supervisingQueue = false
1669
1217
  export function superviseQueue(intervalMs = 3000): void {
1670
1218
  if (supervisingQueue) return
@@ -1676,6 +1224,28 @@ export function superviseQueue(intervalMs = 3000): void {
1676
1224
  void tick()
1677
1225
  }
1678
1226
 
1227
+ let supervisingDelivery = false
1228
+ // @@@ superviseDelivery - the RETRY half of [[delivery-queue]]. `sendText` hands over in its own process, which
1229
+ // covers the live case; this covers everything that could not be handed over then — a harness mid-restart, a
1230
+ // pane in the one state that swallows prompts, a session that was offline when the message arrived. Owned by
1231
+ // the serve that serves this project root, so a message owed to a worker is delivered when the worker can take
1232
+ // it rather than when it happens to run a tool. A tick with nothing owed is one existsSync per session, and
1233
+ // concurrent serves are harmless: the queue's lock, not the process, is what makes a handover exactly-once.
1234
+ export function superviseDelivery(intervalMs = 2000): void {
1235
+ if (supervisingDelivery) return
1236
+ supervisingDelivery = true
1237
+ const tick = async () => {
1238
+ try {
1239
+ for (const id of listSessionIds()) {
1240
+ if (!owesDelivery(id)) continue
1241
+ try { await drainSession(id) } catch { /* an adapter that refused stays owed; next tick retries */ }
1242
+ }
1243
+ } catch { /* transient store read; next tick retries */ }
1244
+ setTimeout(tick, intervalMs).unref()
1245
+ }
1246
+ void tick()
1247
+ }
1248
+
1679
1249
  type TurnFailureObserverState = {
1680
1250
  fingerprint: string
1681
1251
  subscription: FailureSubscription | null
@@ -1776,27 +1346,16 @@ export function superviseTurnFailures(intervalMs = 1000): void {
1776
1346
  tick()
1777
1347
  }
1778
1348
 
1779
- // @@@ assertProjectMatch - a WRITE is PROJECT-BOUND, but routing is by URL. A mutating verb's intent is
1780
- // "act on the project my cwd is in", yet the resolved base is a pure URL carrying no project identity —
1781
- // the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
1782
- // another repo's backend) silently lands the write in the WRONG repo. Read/control-READS deliberately
1783
- // point anywhere (viewer-points-anywhere, see remote-client); every MUTATING verb (new/merge/send/close/
1784
- // rename/input/resume/stop) is bound to the caller's project. So before writing, compare the caller's
1785
- // repo root to the backend's served root and FAIL LOUD on a provable, same-host mismatch — never a silent
1786
- // misroute. An explicit `--api`/`--port` flag SKIPS the guard: the flag is the one provably-deliberate
1787
- // cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
1788
- // mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local path
1789
- // (a genuinely remote backend) all fall through to allow, so legit remote drive stays untouched.
1790
1349
  type BackendSettings = { layout?: { main?: string } }
1791
- function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
1350
+ type BackendInstance = { root?: unknown }
1351
+ function assertProjectRootMatch(verb: string, target: ApiBaseInfo, servedRoot: string | null): void {
1792
1352
  const { url, source } = target
1793
1353
  if (source === 'flag') return // explicitly routed — the caller named the target
1794
1354
  let localMain: string
1795
1355
  try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
1796
- const served = settings?.layout?.main ?? null
1797
- if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
1356
+ if (!servedRoot || !isAbsolute(servedRoot)) return // unknown / config-aliased root → don't risk a false refusal
1798
1357
  let backendMain: string
1799
- try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
1358
+ try { backendMain = realpathSync(servedRoot) } catch { return } // backend root not a local path → a remote backend, allow
1800
1359
  if (backendMain !== localMain) {
1801
1360
  const e = new Error(
1802
1361
  `${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
@@ -1806,6 +1365,16 @@ function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings:
1806
1365
  throw e
1807
1366
  }
1808
1367
  }
1368
+ function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
1369
+ assertProjectRootMatch(verb, target, settings?.layout?.main ?? null)
1370
+ }
1371
+ function assertProjectInstanceMatch(verb: string, target: ApiBaseInfo, instance: BackendInstance | null): void {
1372
+ const root = instance?.root
1373
+ if (typeof root !== 'string' || !isAbsolute(root)) return
1374
+ let servedMain: string
1375
+ try { servedMain = mainRoot(root) } catch { return }
1376
+ assertProjectRootMatch(verb, target, servedMain)
1377
+ }
1809
1378
  export async function assertProjectMatch(verb: string): Promise<void> {
1810
1379
  const target = await apiBaseInfo()
1811
1380
  if (target.source === 'flag') return
@@ -1841,7 +1410,6 @@ type SessionCreateRequestOptions = {
1841
1410
  requestKey?: string
1842
1411
  signal?: AbortSignal
1843
1412
  timeoutMs?: number
1844
- operation?: 'create' | 'fallback-create'
1845
1413
  }
1846
1414
  export type SessionCreateRequestResult =
1847
1415
  | { status: 201; session: Session }
@@ -1909,37 +1477,24 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
1909
1477
  timer.unref?.()
1910
1478
  traceSessionCreate(id, requestDigest, 'request', 'start')
1911
1479
  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
- })
1480
+ try {
1481
+ const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
1482
+ traceSessionCreate(id, requestDigest, 'request', 'finish')
1483
+ return { status: 201, session }
1484
+ } catch (error) {
1485
+ const failure = error instanceof SessionCreateError
1486
+ ? error
1487
+ : controller.signal.aborted
1488
+ ? createAbortError(controller.signal, 'request')
1489
+ : new SessionCreateError('session_create_failed', 'request', String((error as Error).message || error), 400)
1490
+ return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
1491
+ }
1927
1492
  } finally {
1928
1493
  clearTimeout(timer)
1929
1494
  options.signal?.removeEventListener('abort', cancel)
1930
1495
  }
1931
1496
  }
1932
1497
 
1933
- // @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
1934
- // BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
1935
- // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
1936
- // bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
1937
- // comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
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
1498
  function isExplicitConnectionRefused(error: unknown): boolean {
1944
1499
  if (!error || typeof error !== 'object') return false
1945
1500
  if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true
@@ -1951,31 +1506,28 @@ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean
1951
1506
  const controller = new AbortController()
1952
1507
  const timer = setTimeout(() => controller.abort(), 1500)
1953
1508
  timer.unref?.()
1509
+ let response: Response
1954
1510
  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
1511
+ response = await fetch(`${target.url}/api/instance`, { signal: controller.signal })
1963
1512
  } catch (error) {
1513
+ clearTimeout(timer)
1964
1514
  if (isExplicitConnectionRefused(error)) return true
1965
1515
  const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`)
1966
1516
  failed.name = 'BackendError'
1967
1517
  Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error })
1968
1518
  throw failed
1519
+ }
1520
+ try {
1521
+ let instance: BackendInstance | null = null
1522
+ if (response.ok) {
1523
+ try { instance = await response.json() as BackendInstance }
1524
+ catch { /* an HTTP response already established backend authority */ }
1525
+ }
1526
+ assertProjectInstanceMatch('spex session new', target, instance)
1527
+ return false
1969
1528
  } finally { clearTimeout(timer) }
1970
1529
  }
1971
1530
  export async function createSession(prompt: string, launcher?: string): Promise<Session> {
1972
- if (maintenanceBrokerDescriptors()) {
1973
- throw new SessionMaintenanceError('maintenance_capability_missing', 'maintenance operator broker admits only its exact stop/resume plan', { operation: 'create' })
1974
- }
1975
- // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
1976
- // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
1977
- // backend, whose process env carries no acting session id. An agent that runs `spex session new` stamps its own id;
1978
- // a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
1979
1531
  const parent = ownSessionId()
1980
1532
  const requestKey = randomUUID()
1981
1533
  const target = await apiBaseInfo()
@@ -1983,7 +1535,7 @@ export async function createSession(prompt: string, launcher?: string): Promise<
1983
1535
  const refused = await probeSessionCreateAuthority(target)
1984
1536
  if (refused) {
1985
1537
  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' })
1538
+ const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey })
1987
1539
  if (fallback.status === 201) return fallback.session
1988
1540
  const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
1989
1541
  error.name = 'BackendError'
@@ -2016,14 +1568,6 @@ export async function createSession(prompt: string, launcher?: string): Promise<
2016
1568
  return await res.json() as Session
2017
1569
  }
2018
1570
 
2019
- // @@@ spawnerClause - where the SPAWNER works, told to the child ([[spawner-pointer]]). A child's worktree is
2020
- // branched off the BASE branch, never off its spawner, so everything that session has in flight — a spec node
2021
- // it just created, an edit it hasn't landed — is absent from the child's tree AND from the spec index the
2022
- // pointer above resolves against (that index reads the backend's own checkout). Teaching the fork or the
2023
- // landing about nesting would cost a second base per session and would carry the spawner's unreviewed commits
2024
- // into whatever the child merges into; naming the spawner's worktree costs one line and lets the agent decide.
2025
- // A POINTER, never a body — same family rule as [[spec-pointer]] — and fail-quiet by absence: no parent, or a
2026
- // parent record without a worktree, appends nothing.
2027
1571
  export function spawnerClause(p: SessRec | null): string {
2028
1572
  if (!p?.worktreePath) return ''
2029
1573
  const who = p.name || p.title
@@ -2252,14 +1796,6 @@ async function proveSessionCandidate(path: string, branch: string, signal: Abort
2252
1796
  return null
2253
1797
  }
2254
1798
 
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
2257
- // launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
2258
- // immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
2259
- // `spex session new`. Creating or deleting a spec node is NOT a server op — it is prompt-driven work the
2260
- // launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
2261
- // only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
2262
- // programmatic surface, every other surface is prompt only).
2263
1799
  async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
2264
1800
  const { id, requestDigest, payloadHash, signal } = context
2265
1801
  let phase: SessionCreatePhase = 'creation-lock'
@@ -2445,14 +1981,6 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
2445
1981
  }
2446
1982
  }
2447
1983
 
2448
- // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
2449
- // the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and every
2450
- // lifecycle dispatch RIDES ON those hooks — so when this materialize fails, no hook ever fires,
2451
- // and the worker comes up ungoverned (no contract block, no stop-gate) with nothing saying so. Fail loud
2452
- // instead: log the cause + worktree, and stamp the failure on the record's `note` so the board/watch surface
2453
- // it. The launch still proceeds — a visibly degraded worker the human can close + re-dispatch beats a refused
2454
- // launch, and status stays agent-authored ([[state]]): we stamp the note, never an inferred `error` state.
2455
- // `doMaterialize` is injectable only so tests can simulate the failure.
2456
1984
  export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string) => unknown = materialize): void {
2457
1985
  try {
2458
1986
  doMaterialize(rec.worktreePath)
@@ -2461,15 +1989,6 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
2461
1989
  }
2462
1990
  }
2463
1991
 
2464
- // @@@ waitForReady - after a launch/relaunch, the agent needs SEVERAL SECONDS to come up; launch() only TYPES
2465
- // the start line via send-keys and returns immediately, so the agent's online-signal does not exist yet on
2466
- // return. Poll the ADAPTER's liveness ([[harness-adapter]]) at a small interval up to a bounded timeout so the
2467
- // agent counts as "ready" only once it is genuinely online — claude: its rendezvous socket up; codex: its
2468
- // project app-server socket up AND native thread id captured — then a follow-on dispatch (merge / send) lands
2469
- // in a LIVE agent instead of racing the boot and failing loud on a session that is actually recovering.
2470
- // BOUNDED + fail-loud preserved: a
2471
- // genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
2472
- // deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
2473
1992
  const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
2474
1993
  // waitForReady (slot-hold + resume) waits through a daemon-race retry
2475
1994
  // instead of returning before a recovering socket
@@ -2501,68 +2020,9 @@ async function waitForReady(id: string, harness: Harness, pending?: SessRec, tim
2501
2020
  }
2502
2021
  }
2503
2022
 
2504
- // @@@ resumeSession - bring the agent back up and settle its RESTING lifecycle. THREE rules:
2505
- // • RESUME GUARD ([[state]]): a relaunch KILLS the running agent (`kill-session` + fresh window), so it is a
2506
- // data-loss operation the moment the agent is actually ALIVE — the incident's kill-shot was restore-on-alive
2507
- // (the board LIED offline, the human relaunched, live workers died mid-work). So resume re-derives the
2508
- // agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session resume`,
2509
- // `guard` default true), REFUSES LOUD rather than relaunch a live agent — you steer a live agent by
2510
- // MESSAGING it, not by restoring it. Death must be PROVEN: an `unknown` probe (tmux timed out under load —
2511
- // the exact condition that started the incident) also refuses, since a live worker can't be ruled out. A
2512
- // `force` escape exists for a genuinely-wedged-but-alive process. The merge dispatch passes `guard:false`:
2513
- // it only needs a LIVE agent to send the merge prompt to, so an already-online agent is a satisfied no-op
2514
- // (never a refusal), and only a CONFIRMED-offline one is relaunched.
2515
- // • liveness/relaunch: relaunch only when the agent is CONFIRMED `offline` (or `force`) — never on `online`
2516
- // (alive), `starting` (booting), or `unknown` (unproven). We drop any stale pane and launch a fresh window
2517
- // through the adapter's resumeArg — claude `--resume <id>` (the SAME conversation), codex `resume
2518
- // <thread-id>` once captured, else a fresh TUI — then WAIT (waitForReady) so a caller that dispatches
2519
- // immediately after (mergeSession's merge) addresses a LIVE agent, not a racing boot.
2520
- // • lifecycle: the SAME active-only guard markIdle uses — a resumed agent that was WORKING (`active`) is now
2521
- // just sitting at its prompt → `idle`; EVERY deliberate declaration survives untouched (`awaiting` + its
2522
- // proposal, `asking`, `parked`, `error`, `queued`). resume does NOT touch the `proposal` — resuming a
2523
- // session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
2524
- // a refusal leaves the record wholly untouched.
2525
- // Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
2526
- type ResumeOptions = { force?: boolean; guard?: boolean; authorization?: Authorization }
2527
- type ResumeExecutionOptions = ResumeOptions & { ticket?: MaintenanceTicket }
2528
-
2529
- async function delegatedSpawnTransfer(id: string, ticket: MaintenanceTicket): Promise<{
2530
- fifo: string
2531
- done: Promise<void>
2532
- close(): void
2533
- }> {
2534
- const fifo = join(storeDir(id), `.maintenance-delegate-${randomUUID()}.fifo`)
2535
- await pexec('mkfifo', ['-m', '600', fifo])
2536
- const bearer = ticket.delegateSharedSpawn(id)
2537
- const writer = spawn('sh', ['-c', 'cat > "$1"', 'spex-maintenance-delegate', fifo], {
2538
- stdio: ['pipe', 'ignore', 'pipe'],
2539
- })
2540
- let stderr = ''
2541
- writer.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk })
2542
- writer.stdin.on('error', () => {})
2543
- writer.stdin.end(bearer)
2544
- let settled = false
2545
- const done = new Promise<void>((resolve, reject) => {
2546
- writer.once('error', (error) => { settled = true; reject(error) })
2547
- writer.once('close', (code, signal) => {
2548
- settled = true
2549
- if (code === 0) resolve()
2550
- else reject(new SessionMaintenanceError('maintenance_delegate_invalid',
2551
- `shared-spawn delegate transfer failed (${signal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''})`,
2552
- { operation: 'shared-spawn', sessionId: id }))
2553
- })
2554
- })
2555
- return {
2556
- fifo,
2557
- done,
2558
- close() {
2559
- if (!settled) writer.kill('SIGTERM')
2560
- rmSync(fifo, { force: true })
2561
- },
2562
- }
2563
- }
2023
+ type ResumeOptions = { force?: boolean; guard?: boolean }
2564
2024
 
2565
- async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
2025
+ async function resumeSessionUnlocked(id: string, opts: ResumeOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
2566
2026
  const { force = false, guard = true } = opts
2567
2027
  let wt: { path: string; branch: string | null; rec: SessRec } | null
2568
2028
  try { wt = await findWorktree(id) }
@@ -2629,13 +2089,7 @@ async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions =
2629
2089
  const resumed: SessRec = { ...current, archived: false, coldProof: null, status: current.status === 'active' ? 'idle' : current.status, stopped: false }
2630
2090
  if (force || lv === 'offline') {
2631
2091
  await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
2632
- const transfer = opts.authorization && opts.ticket && h.sharedRuntimeSpawn
2633
- ? await delegatedSpawnTransfer(id, opts.ticket)
2634
- : null
2635
- try {
2636
- await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec), transfer?.fifo)
2637
- if (transfer) await transfer.done
2638
- } finally { transfer?.close() }
2092
+ await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
2639
2093
  let readiness: HarnessLaunchReadinessFence | null = null
2640
2094
  let readinessError = ''
2641
2095
  try { readiness = await waitForReady(id, h, resumed) }
@@ -2680,18 +2134,12 @@ async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions =
2680
2134
  return { ok: true }
2681
2135
  }
2682
2136
  export const resumeSession = (id: string, opts: ResumeOptions = {}) =>
2683
- runSessionOperation({ op: 'resume', sessionId: id, force: opts.force === true, ...(opts.authorization ? { authorization: opts.authorization } : {}) },
2684
- (ticket) => withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, { ...opts, ticket }))))
2685
-
2686
- // @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
2687
- // authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
2688
- // SOMETHING changed, not the transition, so they force a write, never infer. The session it writes is resolved
2689
- // by id: `sessionId` (the hooks pass `--session <id>` from the payload) wins, else ownSessionId() (the env var
2690
- // the agent's own `spex session …` carries). Unknown id / no record → false (the route/CLI reports it).
2137
+ withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, opts)))
2138
+
2691
2139
  export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
2692
2140
  const id = opts.sessionId || ownSessionId()
2693
2141
  if (!id) return false
2694
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2142
+ return withRecordLockSync(id, () => {
2695
2143
  const rec = readLiveRecord(id)
2696
2144
  if (!rec) return false
2697
2145
  writeRecord({
@@ -2700,21 +2148,18 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
2700
2148
  note: opts.note ?? null,
2701
2149
  })
2702
2150
  return true
2703
- }))
2151
+ })
2704
2152
  }
2705
2153
  export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
2706
2154
  export const markError = (sessionId?: string) => markState('error', { sessionId })
2707
- // @@@ harness turn failure - native adapter failures are external runtime facts that must become visible on
2708
- // the durable board. Compare-and-set only an undeclared active record, so a declaration that landed before a
2709
- // late process close or app-server completion remains authoritative.
2710
2155
  export function markTurnFailure(sessionId: string | undefined, note: string): boolean {
2711
2156
  if (!sessionId) return false
2712
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId }, () => withRecordLockSync(sessionId, () => {
2157
+ return withRecordLockSync(sessionId, () => {
2713
2158
  const rec = readLiveRecord(sessionId)
2714
2159
  if (!rec || rec.status !== 'active' || rec.stopped || rec.archived) return false
2715
2160
  writeRecord({ ...rec, status: 'error', proposal: null, note })
2716
2161
  return true
2717
- }))
2162
+ })
2718
2163
  }
2719
2164
  export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
2720
2165
  if (exitCode === '0') return false
@@ -2724,49 +2169,54 @@ export function markHeadlessTurnFailure(sessionId: string, harness: string, exit
2724
2169
  export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
2725
2170
  const id = sessionId || ownSessionId()
2726
2171
  if (!id || !harnessSessionId) return false
2727
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2172
+ return withRecordLockSync(id, () => {
2728
2173
  const rec = readLiveRecord(id)
2729
2174
  if (!rec) return false
2730
- writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
2175
+ if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
2176
+ throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`)
2177
+ const codex = rec.harness === 'codex' || rec.harness === 'codex-headless'
2178
+ const root = runtimeRoot()
2179
+ let priorBinding: ReturnType<typeof codexGenerationBindingForSession> = null
2180
+ let generationId: string | undefined
2181
+ let registrationPrepared = false
2182
+ if (codex) {
2183
+ generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim()
2184
+ const ledger = readCodexGenerationLedger(root)
2185
+ if (ledger.revision > 0 && !generationId) throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`)
2186
+ priorBinding = codexGenerationBindingForSession(root, id)
2187
+ if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
2188
+ throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`)
2189
+ if (generationId && !priorBinding) {
2190
+ prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId)
2191
+ registrationPrepared = true
2192
+ }
2193
+ }
2194
+ try {
2195
+ writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
2196
+ } catch (error) {
2197
+ if (codex && generationId && registrationPrepared) {
2198
+ try {
2199
+ bindCodexGeneration(root, id, harnessSessionId, null)
2200
+ } catch (rollback) {
2201
+ throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`)
2202
+ }
2203
+ }
2204
+ throw error
2205
+ }
2206
+ if (codex && generationId) commitCodexGenerationRegistration(root, id, harnessSessionId, generationId)
2731
2207
  return true
2732
- }))
2208
+ })
2733
2209
  }
2734
- // @@@ markIdle - the ONE INFERRED state, so (unlike the agent-authored writers above) it carries a strict
2735
- // active-only guard: the Notification(idle_prompt) hook fires it when claude is waiting at its prompt, and it
2736
- // may ONLY overwrite `active` → `idle`. A deliberate declaration (awaiting / asking / parked / error) must
2737
- // survive — idle only fills the gap where the agent stopped WITHOUT declaring (e.g. an API error killed the
2738
- // turn before the Stop gate). The mark-active hook flips idle → active on resume. Same id resolution as markState.
2739
2210
  export function markIdle(sessionId?: string): boolean {
2740
2211
  const id = sessionId || ownSessionId()
2741
2212
  if (!id) return false
2742
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2213
+ return withRecordLockSync(id, () => {
2743
2214
  const rec = readLiveRecord(id)
2744
2215
  if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
2745
2216
  writeRecord({ ...rec, status: 'idle' })
2746
2217
  return true
2747
- }))
2218
+ })
2748
2219
  }
2749
- // @@@ asking has TWO writers, both deterministic (neither guarded active-only): (1) the mark-active
2750
- // PreToolUse hook captures it the instant the agent invokes the AskUserQuestion tool (status=asking,
2751
- // the question as the note) — a HARD signal that the agent is asking the human; (2) the agent declares it
2752
- // itself via markState('asking', { note }) — `spex session ask`, e.g. at the Stop gate. Either
2753
- // way the mark-active path clears it back to active on the next tool / prompt, same as any non-active state.
2754
-
2755
- // @@@ mergeReadiness - the deterministic commit gate the Stop hook enforces before a session may declare
2756
- // done. The dogfood ritual lands every change as a COMMIT on the node branch first, so an uncommitted
2757
- // working tree blocks EITHER proposal: the declaration claims the work is committed, and a dirty tree makes
2758
- // that false. The second condition — 0 commits ahead of main — is checked ONLY for `merge`, because it is
2759
- // the only one the claim contradicts: `--propose merge` asserts there is committed work to land, while
2760
- // `--propose nothing` asserts the opposite ("committed, but I am NOT proposing a merge; paused for the human
2761
- // to look"), which a lane whose work ALREADY landed states truthfully. Gating `nothing` on ahead-of-main
2762
- // left such a lane one way through: an empty commit — a lie in git history to satisfy a check about honesty. Since the global-store refactor, SpexCode writes NO per-session files into
2763
- // the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are exclude-
2764
- // hidden materialized artifacts or filter-covered contract blocks ([[residence]]), so
2765
- // neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
2766
- // work, no runtime-file filtering needed.
2767
- // Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
2768
- // can't misdirect repo discovery to the cwd (the same trap git.ts documents). `main` resolves via the shared
2769
- // refs, so `main..HEAD` works from any linked worktree regardless of where main is checked out.
2770
2220
  export function mergeReadiness(proposal: 'merge' | 'nothing' = 'merge'): { ready: boolean; reason?: string } {
2771
2221
  let dirty: string[] = []
2772
2222
  try {
@@ -2793,27 +2243,14 @@ function porcelainPath(line: string): string {
2793
2243
  return p
2794
2244
  }
2795
2245
 
2796
- // @@@ MANAGER COCKPIT - the review payload (the cockpit's first verb; see the manager-cockpit spec node).
2797
- // One server-side bundle that lets a manager (human or agent) decide whether to merge a session WITHOUT
2798
- // hand-running git: how far ahead it is, its REAL changes (merge-base diff, never a phantom main..HEAD one),
2799
- // whether uncommitted non-runtime work remains, the merge/lint gates, and the agent's standing proposal.
2800
- // ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); lint reflects the CLI
2801
- // package's OWN location (where this runs) — the spec-cli that's actually live. There is deliberately NO
2802
- // build/typecheck/test gate: whether a change is SOUND is proven by the node's eval scenarios (measured through the
2803
- // real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
2804
- // the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
2805
- // has that id.
2806
- // The measured-loss READOUT beside the git/graph gates. It grades nothing: no threshold, no pass/fail, no
2807
- // block — the manager reads the four mutually exclusive scenario categories [[session-eval]] already folded
2808
- // and decides. Its phase is part of the fact: a projection that has not been computed yet is NOT a clean
2809
- // gate, so an absent/loading/updating/failed projection reports that phase and carries no numbers at all
2810
- // rather than four honest-looking zeros.
2811
2246
  export type ReviewEvalFacts = { freshPass: number; freshFail: number; needReview: number; blind: number }
2812
2247
  export type ReviewEvalGate = ({ phase: 'ready' } & ReviewEvalFacts) | { phase: 'unavailable' | 'loading' | 'updating' | 'error' }
2248
+ // the session-side gates only. The measured-loss readout is composed ABOVE this layer ([[manager-cockpit]]'s
2249
+ // cockpit.ts): the eval package imports this module, so reading it from here could only ever be a deferred
2250
+ // import working around a cycle. The eval side never consumed this field — it reads lint/conflict/ahead/dirty.
2813
2251
  export type ReviewGates = {
2814
2252
  conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
2815
2253
  lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
2816
- evals: ReviewEvalGate // [[session-eval]]'s already-computed scenario categories
2817
2254
  }
2818
2255
  export type ReviewPayload = {
2819
2256
  id: string; node: string | null; branch: string | null
@@ -2863,22 +2300,6 @@ async function lintGate(): Promise<ReviewGates['lint']> {
2863
2300
  return p
2864
2301
  }
2865
2302
 
2866
- // @@@ evalGate - the cockpit's measured-loss readout, taken from [[session-eval]]'s EXISTING projection.
2867
- // This is a cache READ and must stay one: buildSessionEvals() itself calls reviewPayload(), so building the
2868
- // model from here would recurse. The import is dynamic for the same reason the lint gate's is — the eval
2869
- // package imports this module, and the cockpit only needs it at call time.
2870
- async function evalGate(id: string): Promise<ReviewEvalGate> {
2871
- const { sessionEvalProjection } = await import('../../spec-eval/src/sessioneval.js')
2872
- const projection = sessionEvalProjection(id)
2873
- if (!projection) return { phase: 'unavailable' }
2874
- // only a `ready` projection carries a CURRENT value; last-known is deliberately not reported as current.
2875
- if (projection.phase !== 'ready' || !projection.value) {
2876
- return { phase: projection.phase === 'ready' ? 'unavailable' : projection.phase }
2877
- }
2878
- const summary = projection.value
2879
- return { phase: 'ready', freshPass: summary.pass, freshFail: summary.fail, needReview: summary.review, blind: summary.blind }
2880
- }
2881
-
2882
2303
  // @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
2883
2304
  // (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
2884
2305
  // in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
@@ -2888,13 +2309,12 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2888
2309
  const wt = await findWorktree(id)
2889
2310
  if (!wt) return null
2890
2311
  const base = mainBranch()
2891
- const [aheadOut, statusOut, diff, conflictsWithMain, lint, evals] = await Promise.all([
2312
+ const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
2892
2313
  gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
2893
2314
  gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
2894
2315
  mergeBaseDiff(wt.path, base),
2895
2316
  mergeConflicts(wt.path, base),
2896
2317
  lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
2897
- evalGate(id), // measured loss — a READ of the existing projection, never a build
2898
2318
  ])
2899
2319
  // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
2900
2320
  // path is genuine work — this is just the total uncommitted count.
@@ -2904,22 +2324,11 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2904
2324
  label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
2905
2325
  ahead: Number(aheadOut.trim()) || 0,
2906
2326
  dirtyNonRuntime, diff,
2907
- gates: { conflictsWithMain, lint, evals },
2327
+ gates: { conflictsWithMain, lint },
2908
2328
  proposal: { kind: wt.rec.proposal, note: wt.rec.note },
2909
2329
  }
2910
2330
  }
2911
2331
 
2912
- // @@@ mergePrompt - the human's merge INTENT, handed to the session's OWN agent. Merge is a DISPATCH, not a
2913
- // server git script: the agent knows the work, so IT runs the merge, resolves any conflicts, and VERIFIES the
2914
- // outcome — the guarantee lives in that verification, never a server-side gate. This is also the ONE place the
2915
- // merge STYLE is stated (no other mechanism carries it): a --no-ff merge commit `merge <branch>: <reason>`
2916
- // into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree) —
2917
- // and that checkout is the fleet's ONE landing door, so the prompt orders the landing to be TRIVIAL by the
2918
- // time it gets there: sync + resolve in the agent's own worktree, land only when `merge-base --is-ancestor`
2919
- // says the branch already contains the base, wait (never abort) on someone else's in-progress merge. The
2920
- // always-on half of that contract is the `atomic-landing` system plugin; this prompt is the per-merge half.
2921
- // After a clean merge the branch is 0 ahead of main, so the agent proposes CLOSE — not merge (the commit gate
2922
- // would block a merge proposal; propose-close is exempt) — and the human confirms the close.
2923
2332
  function mergePrompt(mainPath: string, branch: string, reason: string): string {
2924
2333
  const base = mainBranch()
2925
2334
  return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself — in YOUR OWN worktree, never in the shared ${base} checkout.\n\n` +
@@ -2930,15 +2339,6 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
2930
2339
  `5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
2931
2340
  }
2932
2341
 
2933
- // @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
2934
- // SESSION'S OWN agent lands the merge, never the server (it carries no `git merge` logic and never touches
2935
- // main's tree). It resumes the session (clears the proposal, `--resume`s via resumeSession if tmux died —
2936
- // which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
2937
- // — that delivered prompt flips the lifecycle to active regardless of resume's resting state
2938
- // through sendText. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
2939
- // the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
2940
- // CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
2941
- // re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
2942
2342
  async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean; reason?: string }> {
2943
2343
  const wt = await findWorktree(id)
2944
2344
  if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
@@ -2954,7 +2354,7 @@ async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean;
2954
2354
  return { dispatched: true }
2955
2355
  }
2956
2356
  export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
2957
- runSessionOperation({ op: 'merge-dispatch', sessionId: id }, () => mergeSessionUnlocked(id))
2357
+ mergeSessionUnlocked(id)
2958
2358
 
2959
2359
  // @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
2960
2360
  // process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
@@ -2974,9 +2374,7 @@ async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, l
2974
2374
  throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
2975
2375
  if (!Number.isFinite(pid) || pid <= 0) return
2976
2376
  const startToken = leaf.startToken
2977
- const alive = (): boolean => {
2978
- try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
2979
- }
2377
+ const alive = (): boolean => leafAlive(pid)
2980
2378
  const identityState = (): 'same' | 'gone' | 'changed' => {
2981
2379
  if (readAgentPid(sessionArtifactPath(id, 'agent.pid')) !== leaf.pid) return 'changed'
2982
2380
  const current = processStartToken(pid)
@@ -3016,6 +2414,14 @@ async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, l
3016
2414
  // the resolved adapter to sweep its ephemeral runtime transport — in that order, because the adapter only
3017
2415
  // removes a transport whose listener is PROVEN dead.
3018
2416
  // Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
2417
+ // @@@ leafAlive - does this pid name a live process? EPERM counts as alive (a process we may not signal is
2418
+ // still a process); only ESRCH is absence. Kept local: git.ts carries its own copy for lock reclamation, and
2419
+ // collapsing the two is part of the spec/eval unification lane, not of this fix.
2420
+ const leafAlive = (pid: number): boolean => {
2421
+ try { process.kill(pid, 0); return true }
2422
+ catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
2423
+ }
2424
+
3019
2425
  async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
3020
2426
  const harness = harnessById(rec.harness || defaultHarness.id)
3021
2427
  if (harness.runtimeOwnership === 'adapter') return null
@@ -3028,8 +2434,8 @@ async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIde
3028
2434
  }
3029
2435
  const startToken = processStartToken(pid)
3030
2436
  if (!startToken) {
3031
- if (rec.stopped) return null
3032
- throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is not alive or has no start identity`)
2437
+ if (rec.stopped || !leafAlive(pid)) return null
2438
+ throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is alive but will not prove its start identity`)
3033
2439
  }
3034
2440
  const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
3035
2441
  const ownerNeedle = harness.leafOwnerNeedle?.(rec)
@@ -3062,11 +2468,6 @@ async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = f
3062
2468
  }
3063
2469
  }
3064
2470
 
3065
- // @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
3066
- // worktree + branch + transcript intact. The retained record's explicit-stop marker makes liveness `offline`
3067
- // whatever its lifecycle or adapter probe, so the relaunch panel offers to --resume the SAME conversation. This is
3068
- // "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
3069
- // the freed capacity drains a queued session next (drainQueue).
3070
2471
  async function stopSessionUnlocked(id: string): Promise<boolean> {
3071
2472
  let wt: { path: string; branch: string | null; rec: SessRec } | null
3072
2473
  try { wt = await findWorktree(id) }
@@ -3082,13 +2483,9 @@ async function stopSessionUnlocked(id: string): Promise<boolean> {
3082
2483
  requestQueueDrain() // a stop frees a slot — start the next queued session if any
3083
2484
  return !!wt
3084
2485
  }
3085
- export const stopSession = (id: string, opts: { authorization?: Authorization } = {}): Promise<boolean> =>
3086
- runSessionOperation({ op: 'stop', sessionId: id, ...(opts.authorization ? { authorization: opts.authorization } : {}) },
3087
- () => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id))))
2486
+ export const stopSession = (id: string): Promise<boolean> =>
2487
+ withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)))
3088
2488
 
3089
- // @@@ archiveSession - cold storage ([[archive]]): prove and stop the exact session-owned runtime first, then
3090
- // write archived:true + stopped:true. The shared adapter root is never torn down. A guard/ownership failure
3091
- // leaves the record unarchived and visible; no second cleanup primitive or fail-open record write exists.
3092
2489
  async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3093
2490
  let wt: { path: string; branch: string | null; rec: SessRec } | null
3094
2491
  try { wt = await findWorktree(id) }
@@ -3104,6 +2501,10 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3104
2501
  archiving.add(id)
3105
2502
  try {
3106
2503
  const h = harnessById(wt.rec.harness || defaultHarness.id)
2504
+ const settleArchiveRecovery = () => {
2505
+ const current = readRecord(id)
2506
+ if (current?.adapterRecovery) writeRecord({ ...current, adapterRecovery: null })
2507
+ }
3107
2508
  // A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
3108
2509
  // adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
3109
2510
  if (wt.rec.archived && hasValidColdProof(wt.rec)) {
@@ -3122,11 +2523,17 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3122
2523
  const state = await descriptor.residency()
3123
2524
  return state.healthy && state.rootAbsent === true && state.referenceIds.length === 0
3124
2525
  })).then((states) => states.some(Boolean))
3125
- if (rootAbsent) return true
2526
+ if (rootAbsent) {
2527
+ settleArchiveRecovery()
2528
+ return true
2529
+ }
3126
2530
  const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true })
3127
2531
  if (!pre || pre.ok) {
3128
2532
  const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true }, pre?.ok ? pre.receipt : undefined)
3129
- if (!cold || cold.ok) return true
2533
+ if (!cold || cold.ok) {
2534
+ settleArchiveRecovery()
2535
+ return true
2536
+ }
3130
2537
  }
3131
2538
  }
3132
2539
  }
@@ -3168,7 +2575,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3168
2575
  : liveness({ ...latest, archived: false, stopped: false }, finalSnap)
3169
2576
  if (finalLv === 'unknown' || finalLv === 'starting' || finalLv === 'online')
3170
2577
  throw new ResourceConflict(`refusing to archive ${id}: leaf became ${finalLv} before filing`)
3171
- writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest) })
2578
+ writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null })
3172
2579
  } catch (error) {
3173
2580
  if (coldCommitted) {
3174
2581
  const restored = await h.restoreRuntime?.(wt.rec, preflight?.ok ? preflight.receipt : undefined)
@@ -3189,8 +2596,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3189
2596
  }
3190
2597
  export const archiveSession = (id: string, on = true): Promise<boolean> => {
3191
2598
  if (!on) return archiveSessionUnarchive(id)
3192
- return runSessionOperation({ op: 'archive', sessionId: id },
3193
- () => withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on))))
2599
+ return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)))
3194
2600
  }
3195
2601
  async function archiveSessionUnarchive(id: string): Promise<boolean> {
3196
2602
  const wt = await findWorktree(id)
@@ -3287,17 +2693,12 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
3287
2693
  }
3288
2694
  }
3289
2695
 
3290
- // @@@ closeSession - the REMOVAL (human-confirmed): a live row uses stop's exact kill, while a proven-cold
3291
- // archive or never-launched queue uses its target-only read proof above. All then remove the worktree + branch and the session's whole
3292
- // global-store record dir — the work is gone, not just stopped. The git/store teardown remains one path.
3293
- // The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
3294
- // so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
3295
- // A corrupt record proves no adapter, leaf, worktree, or branch owner. Close may copy those bytes to the
3296
- // control-plane quarantine, but then fails before this teardown seam and names every residue it preserved.
3297
2696
  async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
3298
2697
  const root = mainRoot()
3299
2698
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
3300
2699
  if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
2700
+ const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId
2701
+ if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
3301
2702
  if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
3302
2703
  else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
3303
2704
  else await stopAgentProcess(id, wt.rec)
@@ -3325,6 +2726,9 @@ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch:
3325
2726
  try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
3326
2727
  catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
3327
2728
  if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
2729
+ if (closesCodexBinding && wt.rec.harnessSessionId) {
2730
+ bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
2731
+ }
3328
2732
  requestQueueDrain() // a close frees a slot — start the next queued session if any
3329
2733
  return true
3330
2734
  }
@@ -3351,8 +2755,7 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
3351
2755
  : closeOwnedSessionUnlocked(id, target)
3352
2756
  }
3353
2757
  export const closeSession = (id: string): Promise<boolean> =>
3354
- runSessionOperation({ op: 'close', sessionId: id },
3355
- () => withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id))))
2758
+ withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
3356
2759
 
3357
2760
  export type CorruptRecordQuarantineWitness = {
3358
2761
  adapter: string
@@ -3446,7 +2849,7 @@ async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuaranti
3446
2849
  if (socket === 'unproven') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport absence is unknown`)
3447
2850
  }
3448
2851
  if (witness.thread) {
3449
- if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} cannot prove and unload an exact native thread`)
2852
+ if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} has no archivable native thread; omit --thread (a SpexCode session id is not an adapter thread)`)
3450
2853
  const native = await harness.quarantineOrphanThread(witness.thread, { excludingSessionId: id })
3451
2854
  if (!native.ok) throw new ResourceConflict(`refusing to quarantine ${id}: ${native.reason}`)
3452
2855
  return { adapter: native.audit.adapter, thread: native.audit.threadId, action: native.audit.action, compensate: native.compensate }
@@ -3464,11 +2867,8 @@ async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuaranti
3464
2867
  return { adapter: harness.id, thread: null, action: 'absent', compensate: async () => ({ ok: true }) }
3465
2868
  }
3466
2869
 
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
2870
  export async function quarantineCorruptRecord(id: string, rawWitness: unknown): Promise<CorruptRecordQuarantineResult> {
3471
- return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
2871
+ return withSessionTransition(id, () => withRecordLock(id, async () => {
3472
2872
  const entry = readRecordEntry(id)
3473
2873
  if (entry.kind === 'absent') throw new ResourceConflict(`refusing to quarantine ${id}: no active session record exists`)
3474
2874
  if (entry.kind === 'ok') throw new ResourceConflict(`refusing to quarantine ${id}: record is readable; use its ordinary lifecycle control`)
@@ -3512,11 +2912,11 @@ export async function quarantineCorruptRecord(id: string, rawWitness: unknown):
3512
2912
  throw new ResourceConflict(`refusing to quarantine ${id}: ${error instanceof Error ? error.message : String(error)}${suffix}`)
3513
2913
  }
3514
2914
  return { id, bundle, sha256, observedAt }
3515
- })))
2915
+ }))
3516
2916
  }
3517
2917
 
3518
2918
  export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecordQuarantineResult> {
3519
- return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
2919
+ return withSessionTransition(id, () => withRecordLock(id, async () => {
3520
2920
  if (readRecordEntry(id).kind !== 'absent') throw new ResourceConflict(`refusing to restore ${id}: an active session record already exists`)
3521
2921
  let bundles: string[]
3522
2922
  try { bundles = readdirSync(quarantineRoot(id), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse() }
@@ -3542,13 +2942,9 @@ export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecor
3542
2942
  throw new ResourceConflict(`refusing to restore ${id}: restored record failed byte-exact verification`)
3543
2943
  }
3544
2944
  return { id, bundle, sha256: provenance.record.sha256, observedAt: new Date().toISOString() }
3545
- })))
2945
+ }))
3546
2946
  }
3547
2947
 
3548
- // @@@ quarantine - closing sweeps the session's whole store dir, so an UNREADABLE record would take the only
3549
- // evidence of what corrupted it with it. Copy those bytes to the per-project `corrupt/` shelf first, named by
3550
- // session id and close time. Only unreadable records are shelved (a healthy one's contents are already known
3551
- // and reproducible); the shelf is never read by the product, it is there for the human who asks "what broke?".
3552
2948
  function quarantineRecord(id: string): string | null {
3553
2949
  let entry
3554
2950
  try { entry = readRecordEntry(id) } catch { return null } // unreadable for another reason (permissions) — leave it
@@ -3719,250 +3115,68 @@ export function formatTable(sessions: Session[], color = true): string {
3719
3115
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
3720
3116
  }
3721
3117
 
3722
- const WATCH_ACTIONABLE = new Set<DisplayStatus>(['review', 'done', 'close-pending', 'offline', 'error', 'asking'])
3723
- const NEXT: Record<string, string> = {
3724
- review: 'merge | close',
3725
- done: 'merge | close',
3726
- 'close-pending': 'close',
3727
- offline: 'resume (relaunch the same conversation)',
3728
- error: 'resume (relaunch & retry) | show --capture | close',
3729
- asking: 'send "<msg>" | show --capture',
3730
- idle: 'send "<msg>" | show --capture',
3731
- queued: 'waiting for a free slot — starts automatically | close',
3732
- }
3733
- export function sessionEvent(s: Session): string {
3734
- const note = s.note ? ` — note: ${s.note}` : ''
3735
- const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
3736
- return `[spex] ${s.status} · ${sessionLabel(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
3737
- }
3738
- // @@@ launchEvent - a session's FIRST sighting. A launch goes straight to 'working' (not actionable), so
3739
- // without this the watch feed would be blind to new sessions starting. Emitted ONCE per id, regardless of
3740
- // status, so `spex session watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
3741
- export function launchEvent(s: Session): string {
3742
- const note = s.note ? ` — note: ${s.note}` : ''
3743
- const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
3744
- return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
3745
- }
3746
- // @@@ source/presenceSource - event population and existence truth are deliberately separate projections. The
3747
- // broad CLI watch passes the default active-only source so it never enumerates the archive shelf, plus an
3748
- // all-record `presenceSource` so a hidden archive is not mistaken for a removed worktree. An explicit selector
3749
- // passes the all-record source for BOTH, allowing that one target's offline archive/resume transitions through.
3750
- // Both are backend clients (client.ts), so `spex session watch` monitors the machine named by
3751
- // SPEXCODE_API_URL; a forgotten source must be a compile error, never a silent local-board fallback.
3752
- export type WatchOpts = { source: () => Promise<Session[]>; presenceSource?: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number; onObserved?: (status: DisplayStatus, previous: DisplayStatus | null) => void } }
3753
- // @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex session wait` runs on); a
3754
- // plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
3755
- // "block for a worker's NEXT transition, then exit" that is GUARANTEED to return. Bounded mode is
3756
- // EDGE-TRIGGERED ([[session-edges]]): it resolves only upon OBSERVING a watched target transition from a
3757
- // non-actionable status INTO an actionable one — an already-actionable first sighting is recorded and
3758
- // narrated (`onObserved`, arrival first) but never resolves, so "wait for the dispatched merge to actually
3759
- // land" has a real signal instead of an instant false return on the standing `review` level. Every observed
3760
- // status per target accumulates into a `path` (arrival first); the resolving target's path rides the outcome
3761
- // so the caller can print the whole observed sequence. The deadline is checked EVERY poll, before
3762
- // EVERY sleep (and even when a poll throws), so a target that never produces an edge — stuck in ANY
3763
- // non-actionable state (`working`/`parked`/`idle`/`queued`/`starting`), or sitting forever on an
3764
- // already-actionable level — can never hang the caller: it exits at the deadline, path carried for the report.
3765
- // `reached` = an observed edge into an actionable status; the rest are the loud exits. `backendDown` is a verdict
3766
- // about the TRANSPORT, never the session — `kind` keeps its two shapes distinct for the caller's outcome
3767
- // surface: 'unreachable' (nothing listening, the whole timeout was spent retrying) vs 'http' (reachable but
3768
- // broken, failed loud at once). The caller must surface these OUTSIDE the session-status vocabulary — a
3769
- // supervisor must never be able to read a transport failure as a session state (issue #40).
3770
- export type WatchOutcome = { reached: DisplayStatus; path: DisplayStatus[] } | { timedOut: true; path: DisplayStatus[] } | { gone: true; path: DisplayStatus[] } | { backendDown: string; kind: 'unreachable' | 'http' }
3771
- export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
3772
- const { source, presenceSource = source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
3773
- const tag = as ? `[${as}] ` : ''
3774
- const prev = new Map<string, DisplayStatus>()
3775
- const paths = new Map<string, DisplayStatus[]>() // bounded mode: every status observed per target, arrival first
3776
- const anyPath = () => (paths.size ? [...paths.values()][0] : [])
3777
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
3778
- // the no-hang wall: a fixed deadline computed ONCE, checked unconditionally every iteration below.
3779
- const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
3780
- const isActionable = (st: DisplayStatus) => WATCH_ACTIONABLE.has(st) || (includeIdle && st === 'idle')
3781
- let warnedDown = false
3782
- let downMsg: string | null = null // set while the backend is unreachable, cleared on a good poll; the deadline reports it
3783
- for (;;) {
3784
- try {
3785
- // EVENTS come from the caller's chosen population (broad watch uses active-only; an explicit selector
3786
- // uses history). EXISTENCE always comes from the all-record presence source, so a cold archive can never
3787
- // look removed merely because the default event population hides it. The `statuses` filter governs only
3788
- // which transitions we emit, never whether a session is present.
3789
- const eventRows = await source()
3790
- // Explicit selector wait passes the same history function for both roles. Reuse that one snapshot so a
3791
- // single poll cannot combine two HTTP instants (or pay for the same request twice); broad watch supplies
3792
- // distinct function references because it genuinely needs active events plus all-record presence.
3793
- const presenceRows = presenceSource === source ? eventRows : await presenceSource()
3794
- const all = selectSessions(eventRows, selectors)
3795
- const presence = selectSessions(presenceRows, selectors)
3796
- warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
3797
- const ids = new Set(presence.map((s) => s.id))
3798
- const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
3799
- let edge: { status: DisplayStatus; path: DisplayStatus[] } | null = null
3800
- for (const s of all) {
3801
- const was = prev.has(s.id) ? prev.get(s.id)! : null
3802
- if (was === null) emit(tag + launchEvent(s)) // FIRST sighting → launched, any status (incl. 'working'), once
3803
- if (s.status === was) continue // only on transition, not every tick
3804
- prev.set(s.id, s.status)
3805
- if (until) {
3806
- const p = paths.get(s.id) ?? []
3807
- p.push(s.status)
3808
- paths.set(s.id, p)
3809
- until.onObserved?.(s.status, was)
3810
- // THE edge: a previously-observed NON-actionable status transitioning INTO an actionable one. An
3811
- // actionable ARRIVAL (was === null) is deliberately not an edge — that standing level is what the
3812
- // old wait false-returned on; and an actionable→actionable hop (review→done) isn't one either. The
3813
- // rise out of non-actionable is the one signal that means "the target needs you AGAIN".
3814
- if (was !== null && !isActionable(was) && isActionable(s.status)) edge = { status: s.status, path: p }
3815
- }
3816
- if (passesStatus(s.status) && (WATCH_ACTIONABLE.has(s.status) || (includeIdle && s.status === 'idle'))) emit(tag + sessionEvent(s))
3817
- }
3818
- // @@@ closed = the worktree is GONE. Because listSessions lists every EXISTING worktree (a flaky detail
3819
- // read degrades, never drops), an id absent from the board means its worktree directory was actually
3820
- // removed: a DEFINITIVE fact, not a flaky absence. So removal needs no 2-poll debounce / existsSync
3821
- // re-check; emit `closed` exactly once the moment the id leaves the list.
3822
- for (const id of [...prev.keys()]) {
3823
- if (ids.has(id)) continue
3824
- prev.delete(id)
3825
- emit(`${tag}[spex] closed \u00b7 removed [id ${id}]`)
3826
- }
3827
- // BOUNDED mode (`until`, what `spex session wait` runs): return on an OBSERVED non-actionable→actionable
3828
- // edge; an empty selected set means the target is gone (absent from the board), which it can never come
3829
- // back from. Both sit inside the try, after the emit pass, so the caller still saw every transition
3830
- // before we hand control back.
3831
- if (until) {
3832
- if (edge) return { reached: edge.status, path: edge.path }
3833
- if (!presence.length) return { gone: true, path: anyPath() }
3834
- }
3835
- } catch (e) {
3836
- // a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
3837
- // we skip the tick (prev is untouched → no phantom removals). Two shapes of BackendError diverge here:
3838
- // • REACHABLE but erroring (an HTTP status) — the backend answered and is broken: a real terminal
3839
- // condition, so a bounded `wait` fails loud IMMEDIATELY rather than spinning out its whole timeout.
3840
- // • UNREACHABLE (no status — ECONNREFUSED / fetch failed) — nothing is listening, e.g. the supervisor
3841
- // is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
3842
- // record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
3843
- // backgrounded `spex session wait` survives the ~1s restart instead of dying on the interrupted fetch.
3844
- if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message, kind: 'http' }
3845
- if (isBackendDown(e)) {
3846
- downMsg = (e as Error).message
3847
- if (!warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${downMsg}; retrying every ${intervalMs / 1000}s…`) }
3848
- }
3849
- }
3850
- // the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep: this
3851
- // guarantees `spex session wait` can never hang on a worker that never produces an edge — nor spin forever on a
3852
- // backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
3853
- // not a false "no edge" timeout, so the manager sees the honest cause.
3854
- if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true, path: anyPath() }
3855
- await sleep(intervalMs)
3856
- }
3857
- }
3858
-
3859
- // @@@ sendText - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
3860
- // ([[harness-adapter]]) — claude the rendezvous control socket (atomic reply+repaint chunk: repaint-done proves
3861
- // the daemon parsed it, a kicked connection resends, wall expiry on a live connection is busy-not-lost → ok),
3862
- // codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
3863
- // fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
3864
- // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
3865
- // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
3866
- // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
3867
- async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
3118
+ // @@@ sendText - THE APPEND ACCEPTS, THE QUEUE OWES ([[dispatch]]). One hold of the record lock records the
3119
+ // message in the durable log AND enqueues it ([[delivery-queue]]); success is decided by that write, so a
3120
+ // sender learns whether the message was accepted and never whether a socket was reachable. The handover is a
3121
+ // separate act: drain the queue into the harness adapter as an ordinary prompt. What stays LOUD is only what
3122
+ // genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
3123
+ // A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
3124
+ // that cannot reach an agent must at least leave a trace ([[session-timeline]]).
3125
+ // (The separate RAW nav-key channel keeps its own `tmux send-keys` path see rawKey.)
3126
+ export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
3868
3127
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
3869
3128
  const rec = readRecord(id)
3870
3129
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
3871
- const h = harnessById(rec.harness || defaultHarness.id)
3872
- // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3873
- // prompt its channel confirms (claude's sessions panel), checkable only from the pane — refuse loudly with
3874
- // the recovery named instead of reporting a false success. A missing pane (window gone, probe failure) skips
3875
- // the guard: the delivery channel itself is the authority on whether the agent is reachable.
3876
- if (h.deliveryBlockedBy) {
3877
- try {
3878
- const blocked = h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', id], TMUX_PROBE_TIMEOUT_MS))
3879
- if (blocked) return { ok: false, error: blocked }
3880
- } catch { /* no pane to consult — let the delivery channel decide */ }
3881
- }
3130
+ // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
3131
+ // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
3132
+ // a message that was already accepted.
3882
3133
  const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3883
- const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), ...(opts.deliveryId ? { deliveryId: opts.deliveryId } : {}) }, prompt.text)
3884
- // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
3885
- // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
3886
- if (r.ok && from) void recordComms(id, from)
3887
- // the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
3888
- if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
3889
- return r
3890
- }
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
3134
  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
3135
+ await withRecordLock(id, async () => {
3136
+ const appended = appendSent(id, text, from ?? null, prompt.replyVia)
3137
+ enqueue(id, { mid: appended.mid, text: prompt.text, from: from ?? null })
3138
+ })
3139
+ } catch (error) {
3140
+ return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
3141
+ }
3142
+ // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
3143
+ // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
3144
+ // the text in a live agent's current turn instead of up to one tick later.
3145
+ await drainSession(id)
3146
+ return { ok: true }
3911
3147
  }
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
3148
+
3149
+ // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
3150
+ // at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
3151
+ // The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
3152
+ export async function drainSession(id: string): Promise<void> {
3153
+ if (!owesDelivery(id)) return
3154
+ const rec = readRecord(id)
3155
+ if (!rec) return
3156
+ const h = harnessById(rec.harness || defaultHarness.id)
3157
+ await drain(id, async (msg) => {
3158
+ // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3159
+ // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
3160
+ // rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
3161
+ if (h.deliveryBlockedBy) {
3928
3162
  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` }
3163
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
3164
+ } catch { /* no pane to consult — let the insert itself decide */ }
3952
3165
  }
3166
+ return (await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)).ok
3953
3167
  })
3954
3168
  }
3955
3169
 
3956
3170
  // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
3957
3171
  // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
3958
3172
  export async function interruptSession(id: string): Promise<DispatchResult> {
3959
- return runSessionOperation({ op: 'interrupt', sessionId: id }, () => withRecordLock(id, async () => {
3173
+ return withRecordLock(id, async () => {
3960
3174
  const rec = readRecord(id)
3961
3175
  if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
3962
3176
  const h = harnessById(rec.harness || defaultHarness.id)
3963
3177
  if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
3964
3178
  return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
3965
- }))
3179
+ })
3966
3180
  }
3967
3181
 
3968
3182
  // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
@@ -4018,7 +3232,7 @@ function rawKeyArgs(id: string, key: string): string[] | null {
4018
3232
  // (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
4019
3233
  // An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
4020
3234
  export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
4021
- return runSessionOperation({ op: 'raw-key-input', sessionId: id }, () => withRecordLock(id, async () => {
3235
+ return withRecordLock(id, async () => {
4022
3236
  const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
4023
3237
  if (list.length === 0 || !(await alive(id))) return false
4024
3238
  let sent = false
@@ -4028,5 +3242,5 @@ export async function rawKey(id: string, key: string | string[]): Promise<boolea
4028
3242
  await tmux(args); sent = true
4029
3243
  }
4030
3244
  return sent
4031
- }))
3245
+ })
4032
3246
  }