spexcode 0.5.7 → 0.5.9

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 (103) 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 +199 -60
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -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/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +42 -30
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -10,62 +10,18 @@ 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
15
  import { stripRefSigil } from './mentions.js'
16
16
  import { shQuote } from './sh.js'
17
17
  import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
18
18
  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.
19
+ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js'
55
20
 
56
21
  const pexec = promisify(execFile)
57
22
  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
23
  const HARNESS = defaultHarness
61
24
  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
25
  const DEFAULT_MAX_ACTIVE = 8
70
26
  function maxActive(): number {
71
27
  let v: number | undefined
@@ -104,23 +60,12 @@ const rvEnv = (id: string, harness = HARNESS) => {
104
60
  ...harness.launchEnv(id), ...homeVars].join(' ')
105
61
  }
106
62
 
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.
63
+ // Re-exported for existing importers.
110
64
  export type { DispatchResult }
111
65
 
112
66
  export type Lifecycle = SessionLifecycle
113
67
  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
68
  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
69
  export type Liveness = 'online' | 'starting' | 'offline' | 'unknown'
125
70
  const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
126
71
 
@@ -139,15 +84,8 @@ export type Session = {
139
84
  sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
140
85
  }
141
86
 
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
87
  function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
145
88
 
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
89
  function writePromptFile(id: string, prompt: string): void {
152
90
  try { writeFileSync(join(storeDir(id), 'prompt'), prompt) } catch { /* best-effort; must never block the launch */ }
153
91
  }
@@ -159,11 +97,7 @@ function readPromptFile(id: string): string | null {
159
97
  return s.trim() ? s : null
160
98
  } catch { return null }
161
99
  }
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).
100
+ // Persist queued launch input across restarts; consume it once the launch begins.
167
101
  function writeLaunchFile(id: string, prompt: string): void {
168
102
  try { writeFileSync(join(storeDir(id), 'launch'), prompt) } catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
169
103
  }
@@ -174,26 +108,18 @@ function removeLaunchFile(id: string): void {
174
108
  try { rmSync(sessionArtifactPath(id, 'launch'), { force: true }) } catch { /* best-effort */ }
175
109
  }
176
110
 
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) || ''
111
+ // One line, bounded the launch prompt's shape when it enters a compact headline.
112
+ export const HEADLINE_PREVIEW_COLUMNS = 60
113
+ function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
114
+ const first = text.split('\n').map((l) => l.trim()).find(Boolean) || ''
180
115
  return first.length > n ? first.slice(0, n - 1) + '…' : first
181
116
  }
182
117
 
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
118
  export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
191
119
  r.name || r.node || r.title || r.branch || r.id
192
120
  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
121
  r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
194
122
 
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
123
  export const sessionLabel = (s: Session): string => s.label
198
124
  export const sessionHeadline = (s: Session): string => s.headline
199
125
 
@@ -217,30 +143,10 @@ function probeTimedOut(e: unknown): boolean {
217
143
  async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
218
144
  export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
219
145
 
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
146
  function pkgRoot(): string {
235
147
  return fileURLToPath(new URL('..', import.meta.url))
236
148
  }
237
149
 
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
150
  export type SessRec = {
245
151
  session: string; governed: boolean; worktreePath: string; branch: string | null
246
152
  node: string | null; title: string | null; name: string | null
@@ -262,10 +168,6 @@ type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 's
262
168
  type LaunchReadinessPending = { version: 1; startedAt: number; original: LaunchReadinessOriginal }
263
169
  export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
264
170
 
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
171
  export function backendLaunchAuthority(env: { SPEXCODE_API_URL?: string; PORT?: string } = process.env): string {
270
172
  const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`
271
173
  const url = new URL(raw)
@@ -299,12 +201,6 @@ function readRecord(id: string): SessRec | null {
299
201
  `session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`)
300
202
  }
301
203
  }
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
204
  export class SessionRecordUnusable extends Error {
309
205
  constructor(readonly code: 'corrupt' | 'retired', readonly session: string, message: string) {
310
206
  super(message)
@@ -313,16 +209,10 @@ export class SessionRecordUnusable extends Error {
313
209
  }
314
210
  const corruptReason = (e: { path: string; error: string }): string =>
315
211
  `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
212
  function retirementReason(rec: SessRec): string | null {
320
213
  if (!rec.worktreePath || existsSync(rec.worktreePath)) return null
321
214
  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
215
  }
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
216
  function readLiveRecord(id: string): SessRec | null {
327
217
  const rec = readRecord(id)
328
218
  if (!rec) return null
@@ -503,27 +393,7 @@ function restoreLaunchReadinessOriginal(rec: SessRec): SessRec {
503
393
  const original = rec.launchReadinessPending?.original
504
394
  return original ? { ...rec, ...original, launchReadinessPending: null } : rec
505
395
  }
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.
396
+ // Rebuild the full disk projection so retired keys disappear on the next write.
527
397
  function writeRecord(rec: SessRec): void {
528
398
  let previous: SessRec | null = null
529
399
  try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
@@ -536,9 +406,6 @@ function writeRecord(rec: SessRec): void {
536
406
  title: rec.title ?? '',
537
407
  name: rec.name ?? '',
538
408
  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
409
  status: rawLifecycleStatus(rec),
543
410
  proposal: rec.proposal ?? '',
544
411
  merges: rec.merges,
@@ -576,11 +443,6 @@ function writeRecord(rec: SessRec): void {
576
443
  const tmp = join(dir, `.session.json.${process.pid}.tmp`)
577
444
  writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n')
578
445
  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
446
  const previousPublic = previous ? publicRecord(previous) : null
585
447
  const nextPublic = publicRecord(rec)
586
448
  if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
@@ -589,42 +451,10 @@ function writeRecord(rec: SessRec): void {
589
451
  }
590
452
  }
591
453
 
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`.
454
+ // Share one liveness snapshot rather than spawning tmux for every displayed session.
622
455
  export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
623
456
 
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.
457
+ // First pane per session wins; split only twice so titles may contain tabs.
628
458
  export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
629
459
  const m = new Map<string, { panePid?: number; title?: string }>()
630
460
  for (const line of out.split('\n')) {
@@ -642,15 +472,7 @@ export function parseLivePanes(out: string): Map<string, { panePid?: number; tit
642
472
  return m
643
473
  }
644
474
 
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.
475
+ // Latch ESRCH per pid-file mtime so a recycled OS PID cannot revive an old session.
654
476
  type PidEntry = { mtimeMs: number; pid: number; deadLatched: boolean }
655
477
  const pidRegistry = new Map<string, PidEntry>()
656
478
  function readAgentPid(p: string): number { try { return Number(readFileSync(p, 'utf8').trim()) } catch { return NaN } }
@@ -670,10 +492,7 @@ function agentAlive(id: string): boolean | undefined {
670
492
  }
671
493
  }
672
494
 
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.
495
+ // Only pre-agent.pid Codex sessions need the legacy whole-process scan.
677
496
  export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean }[]): boolean {
678
497
  return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
679
498
  }
@@ -722,12 +541,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
722
541
  return { probeFailed: false, windows, titles, sockets, unproven }
723
542
  }
724
543
 
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.
544
+ // Avoid process spawns on the hot path; old sessions without agent.pid remain warm-tier only.
731
545
  let hotIds: string[] = []
732
546
  let hotIdsAt = 0
733
547
  export async function hotSignature(): Promise<string> {
@@ -747,10 +561,7 @@ export async function hotSignature(): Promise<string> {
747
561
  return pairs.sort().join(',') + '|' + present.sort().join(',')
748
562
  }
749
563
 
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.
564
+ // Include listener and title changes so watchers refresh without another store read.
754
565
  export async function warmSignature(): Promise<string> {
755
566
  const snap = await liveSnapshot()
756
567
  return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
@@ -805,18 +616,6 @@ export const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans lau
805
616
  const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
806
617
  // exit before readiness is retryable, but it is not proof of one specific cause
807
618
 
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
619
  export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
821
620
  if (!rec.session || rec.stopped || rec.archived) return 'offline'
822
621
  // Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
@@ -842,12 +641,6 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
842
641
  return 'offline'
843
642
  }
844
643
 
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
644
  function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
852
645
  // record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
853
646
  // about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
@@ -869,10 +662,6 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
869
662
  return { path: rec.worktreePath, branch: rec.branch, rec }
870
663
  }
871
664
 
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
665
  function corruptSession(id: string, entry: { path: string; error: string }): Session {
877
666
  const label = `${id.slice(0, 8)} (unreadable record)`
878
667
  return {
@@ -890,38 +679,28 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
890
679
  // dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
891
680
  const showActivity = lv === 'online'
892
681
  const act = showActivity ? activity : null
893
- const pp = prompt ? promptPreview(prompt) : null
682
+ const pp = prompt ? oneLinePreview(prompt) : null
894
683
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
895
684
  const harness = harnessById(rec.harness || defaultHarness.id)
896
685
  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
686
  }
898
687
 
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
688
  export async function renameSession(id: string, name: string): Promise<boolean> {
906
- return runSessionOperation({ op: 'rename', sessionId: id }, () => withRecordLock(id, async () => {
689
+ return withRecordLock(id, async () => {
907
690
  const wt = await findWorktree(id)
908
691
  if (!wt) return false
909
692
  writeRecord({ ...wt.rec, name: name.trim() || null })
910
693
  return true
911
- }))
694
+ })
912
695
  }
913
696
 
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
697
  export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
919
- return runSessionOperation({ op: 'sort', sessionId: id }, () => withRecordLock(id, async () => {
698
+ return withRecordLock(id, async () => {
920
699
  const wt = await findWorktree(id)
921
700
  if (!wt) return false
922
701
  writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
923
702
  return true
924
- }))
703
+ })
925
704
  }
926
705
 
927
706
  // the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded. A record we
@@ -932,17 +711,9 @@ export async function sessionPrompt(id: string): Promise<string | null> {
932
711
  catch (e) { if (e instanceof SessionRecordUnusable) return null; throw e }
933
712
  }
934
713
 
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.
714
+ // Preserve rows through a transient record-read failure; prune after the store entry disappears.
940
715
  const lastKnownSession = new Map<string, Session>()
941
716
 
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
717
  export async function listSessions(includeArchived = false): Promise<Session[]> {
947
718
  // ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
948
719
  // every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
@@ -1032,17 +803,12 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
1032
803
  }, () => {
1033
804
  // DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
1034
805
  // 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.)
806
+ // show yet, and it reappears on the next build.)
1036
807
  return lastKnownSession.get(id) ?? null
1037
808
  }))
1038
809
  // prune last-known entries for ids that no longer appear at all (genuinely removed), keeping it bounded.
1039
810
  const liveIds = new Set(ids)
1040
811
  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
812
  return rows.filter((s): s is Session => s != null && (includeArchived || !s.archived))
1047
813
  .sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
1048
814
  }
@@ -1055,118 +821,6 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
1055
821
  catch { return existsSync(sessionStoreDir(id)) ? degraded() : null }
1056
822
  }
1057
823
 
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
824
  export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
1171
825
  export type ApiBaseInfo = { url: string; source: ApiBaseSource }
1172
826
  const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
@@ -1224,107 +878,27 @@ let apiBaseMemo: Promise<ApiBaseInfo> | null = null
1224
878
  export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
1225
879
  export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
1226
880
 
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
881
  export const ownSessionId = envSessionId
1236
882
 
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
883
  export type MsgSender = { id: string; label: string | null }
1249
884
  export function withSenderHint(text: string, sender: MsgSender | null): string {
1250
885
  if (!sender) return text
1251
886
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
1252
887
  return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
1253
888
  }
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
889
  export const withNoteReplyHint = (text: string): string =>
1265
890
  `${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
891
  export const withTerminalReplyHint = (text: string): string =>
1273
892
  `${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
893
  export const slugify = (s: string | null) =>
1305
894
  (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
1306
895
 
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
896
  const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
1318
897
  export const nodeFromPrompt = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
1319
898
 
1320
899
  type CommandPreset = Pick<ConfigPreset, 'name' | 'body'>
1321
900
  type CommandSpec = Pick<SpecLite, 'id' | 'path'>
1322
901
 
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
902
  export function composeCommandPrompt(raw: string, presets: CommandPreset[], specs: CommandSpec[]): string {
1329
903
  const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/)
1330
904
  if (!match) return raw
@@ -1378,12 +952,9 @@ export async function composeSessionPrompt(raw: string, target: SessionPromptTar
1378
952
  const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
1379
953
  const text = replyVia === 'note' ? withNoteReplyHint(prompt)
1380
954
  : !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
1381
- return { text, ...(replyVia ? { replyVia } : {}) }
955
+ return { text: optionSafe(text), ...(replyVia ? { replyVia } : {}) }
1382
956
  }
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.
957
+ const optionSafe = (text: string) => text.startsWith('-') ? ` ${text}` : text
1387
958
  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
959
  const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
1389
960
  export function titleFromPrompt(prompt: string): string | null {
@@ -1447,8 +1018,7 @@ export function launchPreflight(rec: SessRec): LaunchBlock | null {
1447
1018
 
1448
1019
  // @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
1449
1020
  // 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 {
1021
+ export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
1452
1022
  const file = join(storeDir(id), 'launch.sh')
1453
1023
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
1454
1024
  // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
@@ -1461,11 +1031,11 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1461
1031
  // agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
1462
1032
  // (claude: env→(reclaude→)claude; codex: env→bash -lc <script> whose last line is `exec codex … resume`), and
1463
1033
  // `$$` 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
1034
+ // can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shQuote) so the
1465
1035
  // invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
1466
1036
  // parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
1467
1037
  const pidPath = join(storeDir(id), 'agent.pid')
1468
- const born = `sh -c ${shq1(`printf %s "$$" > ${shq1(pidPath)}; exec env ${invocation}`)}`
1038
+ const born = `sh -c ${shQuote(`printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`
1469
1039
  // Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
1470
1040
  // ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
1471
1041
  // has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
@@ -1508,7 +1078,7 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1508
1078
  // -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
1509
1079
  // can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
1510
1080
  // 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`,
1081
+ ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
1512
1082
  ` 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
1083
  ` exit $__spex_rc`,
1514
1084
  ` fi`,
@@ -1519,36 +1089,21 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1519
1089
  `exit $__spex_rc`,
1520
1090
  ``,
1521
1091
  ]
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'))
1092
+ writeFileSync(file, launchBody.join('\n'))
1529
1093
  return file
1530
1094
  }
1531
- async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): Promise<void> {
1095
+ async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
1532
1096
  // record the transport path THIS runtime hands the agent, before anything reads it (launchScript bakes it
1533
1097
  // into the launch env). Same kind of launch-time fact as agent.pid, and the reason a session's socket is
1534
1098
  // reachable only from the world it belongs to ([[harness-adapter]] rendezvous socket).
1535
1099
  if (harness.ownsRendezvous) stampRvSock(id)
1536
1100
  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)}`])
1101
+ await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
1538
1102
  await tmux(['send-keys', '-t', id, 'Enter'])
1539
1103
  launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
1540
1104
  }
1541
1105
 
1542
1106
 
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
1107
  const OCCUPIES_SLOT = new Set<DisplayStatus>(['working', 'parked', 'starting']) // starting's boot window is also held via `launching`
1553
1108
  function isOccupying(s: Session, snap: LiveSnap): boolean {
1554
1109
  if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
@@ -1606,13 +1161,17 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
1606
1161
  launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
1607
1162
  const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
1608
1163
  try {
1609
- const sq = `'${launchPrompt.replace(/'/g, `'\\''`)}'`
1164
+ const sq = shQuote(launchPrompt)
1610
1165
  await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec))
1611
1166
  } catch {
1612
1167
  launching.delete(id)
1613
1168
  return false // launch failed → stays `queued`, retried on the next drain tick
1614
1169
  }
1615
- writeRecord({ ...wt.rec, status: 'active', proposal: null, launchOwner: null })
1170
+ // the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
1171
+ // launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
1172
+ // belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
1173
+ // headline precedence stands on.
1174
+ writeRecord({ ...wt.rec, status: 'active', proposal: null, note: null, launchOwner: null })
1616
1175
  removeLaunchFile(id) // consumed
1617
1176
  // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
1618
1177
  // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
@@ -1621,11 +1180,6 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
1621
1180
  }
1622
1181
  const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
1623
1182
 
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
1183
  async function drainQueueUnlocked(): Promise<void> {
1630
1184
  if (draining) return
1631
1185
  draining = true
@@ -1651,20 +1205,13 @@ async function drainQueueUnlocked(): Promise<void> {
1651
1205
  }
1652
1206
  } finally { draining = false }
1653
1207
  }
1654
- export const drainQueue = (): Promise<void> => runSessionOperation({ op: 'queue-drain' }, drainQueueUnlocked)
1208
+ export const drainQueue = (): Promise<void> => drainQueueUnlocked()
1655
1209
  const requestQueueDrain = (): void => {
1656
1210
  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
1211
  console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`)
1660
1212
  })
1661
1213
  }
1662
1214
 
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
1215
  let supervisingQueue = false
1669
1216
  export function superviseQueue(intervalMs = 3000): void {
1670
1217
  if (supervisingQueue) return
@@ -1776,27 +1323,16 @@ export function superviseTurnFailures(intervalMs = 1000): void {
1776
1323
  tick()
1777
1324
  }
1778
1325
 
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
1326
  type BackendSettings = { layout?: { main?: string } }
1791
- function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
1327
+ type BackendInstance = { root?: unknown }
1328
+ function assertProjectRootMatch(verb: string, target: ApiBaseInfo, servedRoot: string | null): void {
1792
1329
  const { url, source } = target
1793
1330
  if (source === 'flag') return // explicitly routed — the caller named the target
1794
1331
  let localMain: string
1795
1332
  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
1333
+ if (!servedRoot || !isAbsolute(servedRoot)) return // unknown / config-aliased root → don't risk a false refusal
1798
1334
  let backendMain: string
1799
- try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
1335
+ try { backendMain = realpathSync(servedRoot) } catch { return } // backend root not a local path → a remote backend, allow
1800
1336
  if (backendMain !== localMain) {
1801
1337
  const e = new Error(
1802
1338
  `${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
@@ -1806,6 +1342,16 @@ function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings:
1806
1342
  throw e
1807
1343
  }
1808
1344
  }
1345
+ function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
1346
+ assertProjectRootMatch(verb, target, settings?.layout?.main ?? null)
1347
+ }
1348
+ function assertProjectInstanceMatch(verb: string, target: ApiBaseInfo, instance: BackendInstance | null): void {
1349
+ const root = instance?.root
1350
+ if (typeof root !== 'string' || !isAbsolute(root)) return
1351
+ let servedMain: string
1352
+ try { servedMain = mainRoot(root) } catch { return }
1353
+ assertProjectRootMatch(verb, target, servedMain)
1354
+ }
1809
1355
  export async function assertProjectMatch(verb: string): Promise<void> {
1810
1356
  const target = await apiBaseInfo()
1811
1357
  if (target.source === 'flag') return
@@ -1841,7 +1387,6 @@ type SessionCreateRequestOptions = {
1841
1387
  requestKey?: string
1842
1388
  signal?: AbortSignal
1843
1389
  timeoutMs?: number
1844
- operation?: 'create' | 'fallback-create'
1845
1390
  }
1846
1391
  export type SessionCreateRequestResult =
1847
1392
  | { status: 201; session: Session }
@@ -1909,37 +1454,24 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
1909
1454
  timer.unref?.()
1910
1455
  traceSessionCreate(id, requestDigest, 'request', 'start')
1911
1456
  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
- })
1457
+ try {
1458
+ const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
1459
+ traceSessionCreate(id, requestDigest, 'request', 'finish')
1460
+ return { status: 201, session }
1461
+ } catch (error) {
1462
+ const failure = error instanceof SessionCreateError
1463
+ ? error
1464
+ : controller.signal.aborted
1465
+ ? createAbortError(controller.signal, 'request')
1466
+ : new SessionCreateError('session_create_failed', 'request', String((error as Error).message || error), 400)
1467
+ return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
1468
+ }
1927
1469
  } finally {
1928
1470
  clearTimeout(timer)
1929
1471
  options.signal?.removeEventListener('abort', cancel)
1930
1472
  }
1931
1473
  }
1932
1474
 
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
1475
  function isExplicitConnectionRefused(error: unknown): boolean {
1944
1476
  if (!error || typeof error !== 'object') return false
1945
1477
  if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true
@@ -1951,31 +1483,28 @@ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean
1951
1483
  const controller = new AbortController()
1952
1484
  const timer = setTimeout(() => controller.abort(), 1500)
1953
1485
  timer.unref?.()
1486
+ let response: Response
1954
1487
  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
1488
+ response = await fetch(`${target.url}/api/instance`, { signal: controller.signal })
1963
1489
  } catch (error) {
1490
+ clearTimeout(timer)
1964
1491
  if (isExplicitConnectionRefused(error)) return true
1965
1492
  const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`)
1966
1493
  failed.name = 'BackendError'
1967
1494
  Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error })
1968
1495
  throw failed
1496
+ }
1497
+ try {
1498
+ let instance: BackendInstance | null = null
1499
+ if (response.ok) {
1500
+ try { instance = await response.json() as BackendInstance }
1501
+ catch { /* an HTTP response already established backend authority */ }
1502
+ }
1503
+ assertProjectInstanceMatch('spex session new', target, instance)
1504
+ return false
1969
1505
  } finally { clearTimeout(timer) }
1970
1506
  }
1971
1507
  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
1508
  const parent = ownSessionId()
1980
1509
  const requestKey = randomUUID()
1981
1510
  const target = await apiBaseInfo()
@@ -1983,7 +1512,7 @@ export async function createSession(prompt: string, launcher?: string): Promise<
1983
1512
  const refused = await probeSessionCreateAuthority(target)
1984
1513
  if (refused) {
1985
1514
  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' })
1515
+ const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey })
1987
1516
  if (fallback.status === 201) return fallback.session
1988
1517
  const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
1989
1518
  error.name = 'BackendError'
@@ -2016,14 +1545,6 @@ export async function createSession(prompt: string, launcher?: string): Promise<
2016
1545
  return await res.json() as Session
2017
1546
  }
2018
1547
 
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
1548
  export function spawnerClause(p: SessRec | null): string {
2028
1549
  if (!p?.worktreePath) return ''
2029
1550
  const who = p.name || p.title
@@ -2252,14 +1773,6 @@ async function proveSessionCandidate(path: string, branch: string, signal: Abort
2252
1773
  return null
2253
1774
  }
2254
1775
 
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
1776
  async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
2264
1777
  const { id, requestDigest, payloadHash, signal } = context
2265
1778
  let phase: SessionCreatePhase = 'creation-lock'
@@ -2445,14 +1958,6 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
2445
1958
  }
2446
1959
  }
2447
1960
 
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
1961
  export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string) => unknown = materialize): void {
2457
1962
  try {
2458
1963
  doMaterialize(rec.worktreePath)
@@ -2461,15 +1966,6 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
2461
1966
  }
2462
1967
  }
2463
1968
 
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
1969
  const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
2474
1970
  // waitForReady (slot-hold + resume) waits through a daemon-race retry
2475
1971
  // instead of returning before a recovering socket
@@ -2501,68 +1997,9 @@ async function waitForReady(id: string, harness: Harness, pending?: SessRec, tim
2501
1997
  }
2502
1998
  }
2503
1999
 
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
- }
2000
+ type ResumeOptions = { force?: boolean; guard?: boolean }
2564
2001
 
2565
- async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
2002
+ async function resumeSessionUnlocked(id: string, opts: ResumeOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
2566
2003
  const { force = false, guard = true } = opts
2567
2004
  let wt: { path: string; branch: string | null; rec: SessRec } | null
2568
2005
  try { wt = await findWorktree(id) }
@@ -2629,13 +2066,7 @@ async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions =
2629
2066
  const resumed: SessRec = { ...current, archived: false, coldProof: null, status: current.status === 'active' ? 'idle' : current.status, stopped: false }
2630
2067
  if (force || lv === 'offline') {
2631
2068
  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() }
2069
+ await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
2639
2070
  let readiness: HarnessLaunchReadinessFence | null = null
2640
2071
  let readinessError = ''
2641
2072
  try { readiness = await waitForReady(id, h, resumed) }
@@ -2680,18 +2111,12 @@ async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions =
2680
2111
  return { ok: true }
2681
2112
  }
2682
2113
  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).
2114
+ withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, opts)))
2115
+
2691
2116
  export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
2692
2117
  const id = opts.sessionId || ownSessionId()
2693
2118
  if (!id) return false
2694
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2119
+ return withRecordLockSync(id, () => {
2695
2120
  const rec = readLiveRecord(id)
2696
2121
  if (!rec) return false
2697
2122
  writeRecord({
@@ -2700,21 +2125,18 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
2700
2125
  note: opts.note ?? null,
2701
2126
  })
2702
2127
  return true
2703
- }))
2128
+ })
2704
2129
  }
2705
2130
  export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
2706
2131
  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
2132
  export function markTurnFailure(sessionId: string | undefined, note: string): boolean {
2711
2133
  if (!sessionId) return false
2712
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId }, () => withRecordLockSync(sessionId, () => {
2134
+ return withRecordLockSync(sessionId, () => {
2713
2135
  const rec = readLiveRecord(sessionId)
2714
2136
  if (!rec || rec.status !== 'active' || rec.stopped || rec.archived) return false
2715
2137
  writeRecord({ ...rec, status: 'error', proposal: null, note })
2716
2138
  return true
2717
- }))
2139
+ })
2718
2140
  }
2719
2141
  export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
2720
2142
  if (exitCode === '0') return false
@@ -2724,49 +2146,54 @@ export function markHeadlessTurnFailure(sessionId: string, harness: string, exit
2724
2146
  export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
2725
2147
  const id = sessionId || ownSessionId()
2726
2148
  if (!id || !harnessSessionId) return false
2727
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2149
+ return withRecordLockSync(id, () => {
2728
2150
  const rec = readLiveRecord(id)
2729
2151
  if (!rec) return false
2730
- writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
2152
+ if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
2153
+ throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`)
2154
+ const codex = rec.harness === 'codex' || rec.harness === 'codex-headless'
2155
+ const root = runtimeRoot()
2156
+ let priorBinding: ReturnType<typeof codexGenerationBindingForSession> = null
2157
+ let generationId: string | undefined
2158
+ let registrationPrepared = false
2159
+ if (codex) {
2160
+ generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim()
2161
+ const ledger = readCodexGenerationLedger(root)
2162
+ if (ledger.revision > 0 && !generationId) throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`)
2163
+ priorBinding = codexGenerationBindingForSession(root, id)
2164
+ if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
2165
+ throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`)
2166
+ if (generationId && !priorBinding) {
2167
+ prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId)
2168
+ registrationPrepared = true
2169
+ }
2170
+ }
2171
+ try {
2172
+ writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
2173
+ } catch (error) {
2174
+ if (codex && generationId && registrationPrepared) {
2175
+ try {
2176
+ bindCodexGeneration(root, id, harnessSessionId, null)
2177
+ } catch (rollback) {
2178
+ throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`)
2179
+ }
2180
+ }
2181
+ throw error
2182
+ }
2183
+ if (codex && generationId) commitCodexGenerationRegistration(root, id, harnessSessionId, generationId)
2731
2184
  return true
2732
- }))
2185
+ })
2733
2186
  }
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
2187
  export function markIdle(sessionId?: string): boolean {
2740
2188
  const id = sessionId || ownSessionId()
2741
2189
  if (!id) return false
2742
- return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
2190
+ return withRecordLockSync(id, () => {
2743
2191
  const rec = readLiveRecord(id)
2744
2192
  if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
2745
2193
  writeRecord({ ...rec, status: 'idle' })
2746
2194
  return true
2747
- }))
2195
+ })
2748
2196
  }
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
2197
  export function mergeReadiness(proposal: 'merge' | 'nothing' = 'merge'): { ready: boolean; reason?: string } {
2771
2198
  let dirty: string[] = []
2772
2199
  try {
@@ -2793,27 +2220,14 @@ function porcelainPath(line: string): string {
2793
2220
  return p
2794
2221
  }
2795
2222
 
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
2223
  export type ReviewEvalFacts = { freshPass: number; freshFail: number; needReview: number; blind: number }
2812
2224
  export type ReviewEvalGate = ({ phase: 'ready' } & ReviewEvalFacts) | { phase: 'unavailable' | 'loading' | 'updating' | 'error' }
2225
+ // the session-side gates only. The measured-loss readout is composed ABOVE this layer ([[manager-cockpit]]'s
2226
+ // cockpit.ts): the eval package imports this module, so reading it from here could only ever be a deferred
2227
+ // import working around a cycle. The eval side never consumed this field — it reads lint/conflict/ahead/dirty.
2813
2228
  export type ReviewGates = {
2814
2229
  conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
2815
2230
  lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
2816
- evals: ReviewEvalGate // [[session-eval]]'s already-computed scenario categories
2817
2231
  }
2818
2232
  export type ReviewPayload = {
2819
2233
  id: string; node: string | null; branch: string | null
@@ -2863,22 +2277,6 @@ async function lintGate(): Promise<ReviewGates['lint']> {
2863
2277
  return p
2864
2278
  }
2865
2279
 
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
2280
  // @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
2883
2281
  // (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
2884
2282
  // in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
@@ -2888,13 +2286,12 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2888
2286
  const wt = await findWorktree(id)
2889
2287
  if (!wt) return null
2890
2288
  const base = mainBranch()
2891
- const [aheadOut, statusOut, diff, conflictsWithMain, lint, evals] = await Promise.all([
2289
+ const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
2892
2290
  gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
2893
2291
  gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
2894
2292
  mergeBaseDiff(wt.path, base),
2895
2293
  mergeConflicts(wt.path, base),
2896
2294
  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
2295
  ])
2899
2296
  // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
2900
2297
  // path is genuine work — this is just the total uncommitted count.
@@ -2904,22 +2301,11 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
2904
2301
  label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
2905
2302
  ahead: Number(aheadOut.trim()) || 0,
2906
2303
  dirtyNonRuntime, diff,
2907
- gates: { conflictsWithMain, lint, evals },
2304
+ gates: { conflictsWithMain, lint },
2908
2305
  proposal: { kind: wt.rec.proposal, note: wt.rec.note },
2909
2306
  }
2910
2307
  }
2911
2308
 
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
2309
  function mergePrompt(mainPath: string, branch: string, reason: string): string {
2924
2310
  const base = mainBranch()
2925
2311
  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 +2316,6 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
2930
2316
  `5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
2931
2317
  }
2932
2318
 
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
2319
  async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean; reason?: string }> {
2943
2320
  const wt = await findWorktree(id)
2944
2321
  if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
@@ -2954,7 +2331,7 @@ async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean;
2954
2331
  return { dispatched: true }
2955
2332
  }
2956
2333
  export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
2957
- runSessionOperation({ op: 'merge-dispatch', sessionId: id }, () => mergeSessionUnlocked(id))
2334
+ mergeSessionUnlocked(id)
2958
2335
 
2959
2336
  // @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
2960
2337
  // process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
@@ -2974,9 +2351,7 @@ async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, l
2974
2351
  throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
2975
2352
  if (!Number.isFinite(pid) || pid <= 0) return
2976
2353
  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
- }
2354
+ const alive = (): boolean => leafAlive(pid)
2980
2355
  const identityState = (): 'same' | 'gone' | 'changed' => {
2981
2356
  if (readAgentPid(sessionArtifactPath(id, 'agent.pid')) !== leaf.pid) return 'changed'
2982
2357
  const current = processStartToken(pid)
@@ -3016,6 +2391,14 @@ async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, l
3016
2391
  // the resolved adapter to sweep its ephemeral runtime transport — in that order, because the adapter only
3017
2392
  // removes a transport whose listener is PROVEN dead.
3018
2393
  // Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
2394
+ // @@@ leafAlive - does this pid name a live process? EPERM counts as alive (a process we may not signal is
2395
+ // still a process); only ESRCH is absence. Kept local: git.ts carries its own copy for lock reclamation, and
2396
+ // collapsing the two is part of the spec/eval unification lane, not of this fix.
2397
+ const leafAlive = (pid: number): boolean => {
2398
+ try { process.kill(pid, 0); return true }
2399
+ catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
2400
+ }
2401
+
3019
2402
  async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
3020
2403
  const harness = harnessById(rec.harness || defaultHarness.id)
3021
2404
  if (harness.runtimeOwnership === 'adapter') return null
@@ -3028,8 +2411,8 @@ async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIde
3028
2411
  }
3029
2412
  const startToken = processStartToken(pid)
3030
2413
  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`)
2414
+ if (rec.stopped || !leafAlive(pid)) return null
2415
+ throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is alive but will not prove its start identity`)
3033
2416
  }
3034
2417
  const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
3035
2418
  const ownerNeedle = harness.leafOwnerNeedle?.(rec)
@@ -3062,11 +2445,6 @@ async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = f
3062
2445
  }
3063
2446
  }
3064
2447
 
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
2448
  async function stopSessionUnlocked(id: string): Promise<boolean> {
3071
2449
  let wt: { path: string; branch: string | null; rec: SessRec } | null
3072
2450
  try { wt = await findWorktree(id) }
@@ -3082,13 +2460,9 @@ async function stopSessionUnlocked(id: string): Promise<boolean> {
3082
2460
  requestQueueDrain() // a stop frees a slot — start the next queued session if any
3083
2461
  return !!wt
3084
2462
  }
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))))
2463
+ export const stopSession = (id: string): Promise<boolean> =>
2464
+ withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)))
3088
2465
 
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
2466
  async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3093
2467
  let wt: { path: string; branch: string | null; rec: SessRec } | null
3094
2468
  try { wt = await findWorktree(id) }
@@ -3104,6 +2478,10 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3104
2478
  archiving.add(id)
3105
2479
  try {
3106
2480
  const h = harnessById(wt.rec.harness || defaultHarness.id)
2481
+ const settleArchiveRecovery = () => {
2482
+ const current = readRecord(id)
2483
+ if (current?.adapterRecovery) writeRecord({ ...current, adapterRecovery: null })
2484
+ }
3107
2485
  // A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
3108
2486
  // adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
3109
2487
  if (wt.rec.archived && hasValidColdProof(wt.rec)) {
@@ -3122,11 +2500,17 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3122
2500
  const state = await descriptor.residency()
3123
2501
  return state.healthy && state.rootAbsent === true && state.referenceIds.length === 0
3124
2502
  })).then((states) => states.some(Boolean))
3125
- if (rootAbsent) return true
2503
+ if (rootAbsent) {
2504
+ settleArchiveRecovery()
2505
+ return true
2506
+ }
3126
2507
  const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true })
3127
2508
  if (!pre || pre.ok) {
3128
2509
  const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true }, pre?.ok ? pre.receipt : undefined)
3129
- if (!cold || cold.ok) return true
2510
+ if (!cold || cold.ok) {
2511
+ settleArchiveRecovery()
2512
+ return true
2513
+ }
3130
2514
  }
3131
2515
  }
3132
2516
  }
@@ -3168,7 +2552,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3168
2552
  : liveness({ ...latest, archived: false, stopped: false }, finalSnap)
3169
2553
  if (finalLv === 'unknown' || finalLv === 'starting' || finalLv === 'online')
3170
2554
  throw new ResourceConflict(`refusing to archive ${id}: leaf became ${finalLv} before filing`)
3171
- writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest) })
2555
+ writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null })
3172
2556
  } catch (error) {
3173
2557
  if (coldCommitted) {
3174
2558
  const restored = await h.restoreRuntime?.(wt.rec, preflight?.ok ? preflight.receipt : undefined)
@@ -3189,8 +2573,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
3189
2573
  }
3190
2574
  export const archiveSession = (id: string, on = true): Promise<boolean> => {
3191
2575
  if (!on) return archiveSessionUnarchive(id)
3192
- return runSessionOperation({ op: 'archive', sessionId: id },
3193
- () => withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on))))
2576
+ return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)))
3194
2577
  }
3195
2578
  async function archiveSessionUnarchive(id: string): Promise<boolean> {
3196
2579
  const wt = await findWorktree(id)
@@ -3287,17 +2670,12 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
3287
2670
  }
3288
2671
  }
3289
2672
 
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
2673
  async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
3298
2674
  const root = mainRoot()
3299
2675
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
3300
2676
  if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
2677
+ const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId
2678
+ if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
3301
2679
  if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
3302
2680
  else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
3303
2681
  else await stopAgentProcess(id, wt.rec)
@@ -3325,6 +2703,9 @@ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch:
3325
2703
  try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
3326
2704
  catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
3327
2705
  if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
2706
+ if (closesCodexBinding && wt.rec.harnessSessionId) {
2707
+ bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
2708
+ }
3328
2709
  requestQueueDrain() // a close frees a slot — start the next queued session if any
3329
2710
  return true
3330
2711
  }
@@ -3351,8 +2732,7 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
3351
2732
  : closeOwnedSessionUnlocked(id, target)
3352
2733
  }
3353
2734
  export const closeSession = (id: string): Promise<boolean> =>
3354
- runSessionOperation({ op: 'close', sessionId: id },
3355
- () => withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id))))
2735
+ withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
3356
2736
 
3357
2737
  export type CorruptRecordQuarantineWitness = {
3358
2738
  adapter: string
@@ -3446,7 +2826,7 @@ async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuaranti
3446
2826
  if (socket === 'unproven') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport absence is unknown`)
3447
2827
  }
3448
2828
  if (witness.thread) {
3449
- if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} cannot prove and unload an exact native thread`)
2829
+ 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
2830
  const native = await harness.quarantineOrphanThread(witness.thread, { excludingSessionId: id })
3451
2831
  if (!native.ok) throw new ResourceConflict(`refusing to quarantine ${id}: ${native.reason}`)
3452
2832
  return { adapter: native.audit.adapter, thread: native.audit.threadId, action: native.audit.action, compensate: native.compensate }
@@ -3464,11 +2844,8 @@ async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuaranti
3464
2844
  return { adapter: harness.id, thread: null, action: 'absent', compensate: async () => ({ ok: true }) }
3465
2845
  }
3466
2846
 
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
2847
  export async function quarantineCorruptRecord(id: string, rawWitness: unknown): Promise<CorruptRecordQuarantineResult> {
3471
- return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
2848
+ return withSessionTransition(id, () => withRecordLock(id, async () => {
3472
2849
  const entry = readRecordEntry(id)
3473
2850
  if (entry.kind === 'absent') throw new ResourceConflict(`refusing to quarantine ${id}: no active session record exists`)
3474
2851
  if (entry.kind === 'ok') throw new ResourceConflict(`refusing to quarantine ${id}: record is readable; use its ordinary lifecycle control`)
@@ -3512,11 +2889,11 @@ export async function quarantineCorruptRecord(id: string, rawWitness: unknown):
3512
2889
  throw new ResourceConflict(`refusing to quarantine ${id}: ${error instanceof Error ? error.message : String(error)}${suffix}`)
3513
2890
  }
3514
2891
  return { id, bundle, sha256, observedAt }
3515
- })))
2892
+ }))
3516
2893
  }
3517
2894
 
3518
2895
  export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecordQuarantineResult> {
3519
- return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
2896
+ return withSessionTransition(id, () => withRecordLock(id, async () => {
3520
2897
  if (readRecordEntry(id).kind !== 'absent') throw new ResourceConflict(`refusing to restore ${id}: an active session record already exists`)
3521
2898
  let bundles: string[]
3522
2899
  try { bundles = readdirSync(quarantineRoot(id), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse() }
@@ -3542,13 +2919,9 @@ export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecor
3542
2919
  throw new ResourceConflict(`refusing to restore ${id}: restored record failed byte-exact verification`)
3543
2920
  }
3544
2921
  return { id, bundle, sha256: provenance.record.sha256, observedAt: new Date().toISOString() }
3545
- })))
2922
+ }))
3546
2923
  }
3547
2924
 
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
2925
  function quarantineRecord(id: string): string | null {
3553
2926
  let entry
3554
2927
  try { entry = readRecordEntry(id) } catch { return null } // unreadable for another reason (permissions) — leave it
@@ -3719,250 +3092,62 @@ export function formatTable(sessions: Session[], color = true): string {
3719
3092
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
3720
3093
  }
3721
3094
 
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> {
3095
+ // @@@ sendText - THE APPEND IS THE DELIVERY ([[dispatch]]). The message lands in the target's durable log
3096
+ // under its record lock, and success is decided there; only then is the harness adapter poked with the same
3097
+ // text, so a live agent sees it in its current turn instead of at its next turn boundary. The poke is
3098
+ // best-effort losing it, having it refused, or replaying it costs nothing, because the line is already the
3099
+ // message's copy and the turn-boundary reader picks up whatever the poke did not show. What stays LOUD is only
3100
+ // what genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
3101
+ // A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
3102
+ // that cannot reach an agent must at least leave a trace ([[session-timeline]]).
3103
+ // (The separate RAW nav-key channel keeps its own `tmux send-keys` path see rawKey.)
3104
+ export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
3868
3105
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
3869
3106
  const rec = readRecord(id)
3870
3107
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
3108
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3109
+ let sent: { mid: string }
3110
+ try {
3111
+ // The lock covers the append alone. Codex's native turn can synchronously run hooks that write this same
3112
+ // record, so holding it across the adapter poke below would deadlock the app-server's confirmation.
3113
+ sent = await withRecordLock(id, async () => appendSent(id, text, from ?? null, prompt.replyVia))
3114
+ } catch (error) {
3115
+ return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
3116
+ }
3871
3117
  const h = harnessById(rec.harness || defaultHarness.id)
3118
+ // Awaited, not fire-and-forget: `spex session send` is a short-lived process that would exit before an
3119
+ // unawaited poke ever reached the socket, costing every CLI send its same-turn arrival. Its result never
3120
+ // advances the inbox: a write cannot prove the target parsed it, so only the target's reader consumes the
3121
+ // durable line.
3122
+ await pokeAdapter(h, rec, prompt.text, sent.mid)
3123
+ return { ok: true }
3124
+ }
3125
+
3126
+ // The courtesy kick. Carries `mid` as the adapter's native message marker, so an adapter that replays or
3127
+ // duplicates it stays harmless. Never throws and never reports: a poke has no outcome the caller can act on.
3128
+ async function pokeAdapter(h: Harness, rec: SessRec, text: string, mid: string): Promise<void> {
3872
3129
  // 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.
3130
+ // prompt its channel confirms (claude's sessions panel), checkable only from the pane. It no longer refuses
3131
+ // the send the message is already delivered it only skips a kick known to be swallowed.
3876
3132
  if (h.deliveryBlockedBy) {
3877
3133
  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 */ }
3134
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return
3135
+ } catch { /* no pane to consult — let the poke itself decide */ }
3881
3136
  }
3882
- 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
- try {
3902
- const lines = readFileSync(deliveryLedgerPath(id), 'utf8').split('\n')
3903
- for (let index = lines.length - 1; index >= 0; index--) {
3904
- try {
3905
- const entry = JSON.parse(lines[index]) as DeliveryLedgerEntry
3906
- if (entry?.deliveryId === deliveryId && typeof entry.fingerprint === 'string') return entry
3907
- } catch { /* one corrupt append must not invent a delivery result */ }
3908
- }
3909
- } catch { /* no delivery ledger yet */ }
3910
- return null
3911
- }
3912
- function appendDeliveryLedger(id: string, entry: DeliveryLedgerEntry): void {
3913
- appendFileSync(join(storeDir(id), 'deliveries.ndjson'), JSON.stringify(entry) + '\n')
3914
- }
3915
- function terminalDispatch(result: DispatchResult): DispatchResult {
3916
- return { ...result, outcome: result.outcome ?? (result.ok ? 'accepted' : 'rejected') }
3917
- }
3918
- export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
3919
- // The lock owns only the durable reservation and result. Codex's native turn can synchronously run hooks
3920
- // that write this same record, so holding it across an adapter RPC deadlocks the app-server's confirmation.
3921
- return runSessionOperation({ op: 'send', sessionId: id }, async () => {
3922
- const deliveryId = opts.deliveryId?.trim()
3923
- if (!deliveryId) return terminalDispatch(await sendTextUnlocked(id, text, from, opts))
3924
- const fingerprint = deliveryFingerprint(text, from, opts.replyVia)
3925
- const prior = await withRecordLock(id, async () => {
3926
- const existing = readDeliveryLedger(id, deliveryId)
3927
- if (existing) return existing
3928
- try {
3929
- appendDeliveryLedger(id, { deliveryId, fingerprint })
3930
- return null
3931
- } catch (error) {
3932
- return { deliveryId, fingerprint, result: { ok: false, outcome: 'rejected', error: `could not reserve delivery marker: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` } satisfies DispatchResult }
3933
- }
3934
- })
3935
- if (prior) {
3936
- if (prior.fingerprint !== fingerprint)
3937
- return { ok: false, outcome: 'rejected', error: 'delivery marker belongs to a different prompt — prompt NOT delivered' }
3938
- return prior.result
3939
- ? terminalDispatch(prior.result)
3940
- : { ok: false, outcome: 'commit-unknown', error: 'delivery marker is already reserved without a terminal outcome — prompt NOT replayed' }
3941
- }
3942
- const result = terminalDispatch(await sendTextUnlocked(id, text, from, { ...opts, deliveryId }))
3943
- try {
3944
- return await withRecordLock(id, async () => {
3945
- if (!readRecord(id))
3946
- return { ok: false, outcome: 'commit-unknown', error: 'session closed before delivery outcome could be recorded — prompt NOT replayed' }
3947
- appendDeliveryLedger(id, { deliveryId, fingerprint, result })
3948
- return result
3949
- })
3950
- } catch (error) {
3951
- return { ok: false, outcome: 'commit-unknown', error: `delivery outcome could not be recorded: ${error instanceof Error ? error.message : String(error)} — prompt NOT replayed` }
3952
- }
3953
- })
3137
+ try { await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid }, text) }
3138
+ catch { /* the unread timeline line remains the delivery */ }
3954
3139
  }
3955
3140
 
3956
3141
  // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
3957
3142
  // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
3958
3143
  export async function interruptSession(id: string): Promise<DispatchResult> {
3959
- return runSessionOperation({ op: 'interrupt', sessionId: id }, () => withRecordLock(id, async () => {
3144
+ return withRecordLock(id, async () => {
3960
3145
  const rec = readRecord(id)
3961
3146
  if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
3962
3147
  const h = harnessById(rec.harness || defaultHarness.id)
3963
3148
  if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
3964
3149
  return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
3965
- }))
3150
+ })
3966
3151
  }
3967
3152
 
3968
3153
  // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
@@ -4018,7 +3203,7 @@ function rawKeyArgs(id: string, key: string): string[] | null {
4018
3203
  // (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
4019
3204
  // An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
4020
3205
  export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
4021
- return runSessionOperation({ op: 'raw-key-input', sessionId: id }, () => withRecordLock(id, async () => {
3206
+ return withRecordLock(id, async () => {
4022
3207
  const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
4023
3208
  if (list.length === 0 || !(await alive(id))) return false
4024
3209
  let sent = false
@@ -4028,5 +3213,5 @@ export async function rawKey(id: string, key: string | string[]): Promise<boolea
4028
3213
  await tmux(args); sent = true
4029
3214
  }
4030
3215
  return sent
4031
- }))
3216
+ })
4032
3217
  }