spexcode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/bin/spex.mjs +15 -0
  2. package/dashboard-dist/assets/index-B60MILFg.js +139 -0
  3. package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
  4. package/dashboard-dist/index.html +16 -0
  5. package/package.json +35 -0
  6. package/src/board.ts +119 -0
  7. package/src/cli.ts +487 -0
  8. package/src/client.ts +102 -0
  9. package/src/gateway.ts +241 -0
  10. package/src/git.ts +492 -0
  11. package/src/guide.ts +134 -0
  12. package/src/harness.ts +674 -0
  13. package/src/hooks.ts +41 -0
  14. package/src/index.ts +233 -0
  15. package/src/init.ts +120 -0
  16. package/src/layout.ts +246 -0
  17. package/src/lint.ts +206 -0
  18. package/src/login-page.ts +79 -0
  19. package/src/materialize.ts +85 -0
  20. package/src/pty-bridge.ts +235 -0
  21. package/src/ranker.ts +129 -0
  22. package/src/resilience.ts +41 -0
  23. package/src/search.bench.mjs +47 -0
  24. package/src/search.ts +24 -0
  25. package/src/self.ts +256 -0
  26. package/src/sessions.ts +1469 -0
  27. package/src/slash-commands.ts +242 -0
  28. package/src/specs.ts +331 -0
  29. package/src/supervise.ts +158 -0
  30. package/src/uploads.ts +31 -0
  31. package/templates/hooks/pre-commit +57 -0
  32. package/templates/hooks/prepare-commit-msg +14 -0
  33. package/templates/spec/project/.config/core/idle/idle.sh +15 -0
  34. package/templates/spec/project/.config/core/idle/spec.md +13 -0
  35. package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
  36. package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
  37. package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
  38. package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
  39. package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
  40. package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
  41. package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
  42. package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
  43. package/templates/spec/project/.config/core/spec.md +13 -0
  44. package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
  45. package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
  46. package/templates/spec/project/.config/extract/spec.md +60 -0
  47. package/templates/spec/project/.config/forge-link/spec.md +9 -0
  48. package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
  49. package/templates/spec/project/.config/regroup/spec.md +25 -0
  50. package/templates/spec/project/.config/scenario/spec.md +32 -0
  51. package/templates/spec/project/.config/spec.md +15 -0
  52. package/templates/spec/project/.config/supervisor/spec.md +8 -0
  53. package/templates/spec/project/.config/tidy/spec.md +25 -0
  54. package/templates/spec/project/spec.md +19 -0
  55. package/templates/spexcode.json +5 -0
@@ -0,0 +1,1469 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync } from 'node:fs'
5
+ import { join, dirname, relative } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
8
+ import { loadSpecs } from './specs.js'
9
+ import { defaultHarness, harnessById, rvSock, type Harness, type DispatchResult } from './harness.js'
10
+ import { materialize } from './materialize.js'
11
+ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
12
+
13
+ // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
14
+ // SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
15
+ // session_id (NOT a worktree file — the worktree stays pristine), surviving a kill / reboot / moving the
16
+ // folder. We launch claude with `--session-id <id>` (id we choose) so the SAME conversation can be
17
+ // `--resume`d into a fresh tmux. NO in-memory map: listSessions() ENUMERATES that store every time.
18
+ //
19
+ // STATE MACHINE — two ORTHOGONAL axes (see [[state]]): an agent-authored LIFECYCLE and a runtime-derived
20
+ // LIVENESS, neither overriding the other.
21
+ // lifecycle (authored): active | idle | awaiting | parked | error | asking | queued. `idle` is the ONE
22
+ // inferred one (the Notification(idle_prompt) hook, guarded active-only so it never clobbers a
23
+ // declaration; mark-active flips it back to active on real work).
24
+ // liveness (derived for EVERY session): online | starting | offline. offline = no tmux for the id, or the
25
+ // harness online-signal (claude's rendezvous socket) is gone past the boot grace; starting =
26
+ // the boot window. reconcile composes the two into the compact DisplayStatus for one-glyph surfaces.
27
+ // awaiting → the agent's PROPOSAL, awaiting a human:
28
+ // proposal=merge → shown "review" ("ready, merge me")
29
+ // proposal=nothing → shown "done" ("finished, your call")
30
+ // proposal=close → shown "close-pending" ("I suggest discarding this worktree")
31
+ // asking → the agent is pausing to ask the HUMAN a question. Written DETERMINISTICALLY two ways: the
32
+ // mark-active PreToolUse hook captures it the moment the agent invokes the AskUserQuestion
33
+ // tool (question → note), and the agent may also declare it via `spex session ask --note
34
+ // <question>`. Not inferred. Distinct from `parked` (which waits on a background task/
35
+ // schedule and self-resumes); an asking agent resumes only when a human sends it a prompt.
36
+ // queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
37
+ // (closed = the worktree AND the global record are removed; not a stored status)
38
+ // The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible
39
+ // via reopen() → active. `merges` is METADATA (how many times merged), shown as a badge, not a state.
40
+ //
41
+ // Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
42
+ // SPEXCODE_TMUX / SPEXCODE_CLAUDE_CMD override both for tests.
43
+
44
+ const pexec = promisify(execFile)
45
+ const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
46
+ // the harness the dashboard/CLI launcher drives. ALL harness-specific launch facts (the agent command, the
47
+ // session-id flag, the hook shim, the session env var) come from this adapter — the launcher never names
48
+ // Claude. Resolved once here ([[harness-adapter]]); a future codex launcher flips defaultHarness, nothing else.
49
+ const HARNESS = defaultHarness
50
+ const COLS = 120, ROWS = 32
51
+ // @@@ concurrency cap - the most working agents we let run AT ONCE. Heavy multi-agent load (many claude
52
+ // processes computing simultaneously) was the source of resource-pressure crashes, so a launch beyond the
53
+ // cap is QUEUED, not started: it becomes a durable `queued` worktree that the drainer launches the moment a
54
+ // slot frees (an agent stops working/dies). NOT hardcoded — configured PER PROJECT in `spexcode.json`
55
+ // (`sessions.maxActive`), so a box can be tuned to its capacity without touching the toolchain. Precedence:
56
+ // spexcode.json → `SPEXCODE_MAX_ACTIVE` env → default 6. Read LIVE (cheap file read) so an edit takes effect
57
+ // on the next drain tick, no restart. Floored at 1 so a bad value can't wedge the queue to 0.
58
+ function maxActive(): number {
59
+ let v: number | undefined
60
+ try {
61
+ const fromJson = readConfig(mainRoot()).sessions?.maxActive
62
+ if (typeof fromJson === 'number' && Number.isFinite(fromJson)) v = fromJson
63
+ } catch { /* config unreadable — fall through to env/default */ }
64
+ if (v === undefined) { const e = Number(process.env.SPEXCODE_MAX_ACTIVE); if (Number.isFinite(e) && e > 0) v = e }
65
+ return Math.max(1, Math.floor(v ?? 6))
66
+ }
67
+
68
+ // the rendezvous control socket path + its prompt-delivery/liveness logic now live in the [[harness-adapter]]
69
+ // (claude OWNS the rendezvous; codex does not), so product code asks the adapter rather than hard-wiring it.
70
+ // rvSock is imported only for the two NON-delivery uses that remain product-level: building the launch env var
71
+ // (rvEnv, below) and the best-effort socket sweep on close.
72
+ // env prefix put in front of the spawned agent so it creates this session's rendezvous control socket — and
73
+ // so its hooks + materialize render to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
74
+ // propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
75
+ // an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
76
+ // ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
77
+ const rvEnv = (id: string, harness = HARNESS) => {
78
+ // SPEXCODE_SESSION_ID is the GOVERNED record id every hook resolves (hp_session_id prefers it) — it makes a
79
+ // codex session, whose own thread id is un-pinnable, still feed its governed record; harmless for claude
80
+ // (= its pinned id). The CLAUDE_BG rendezvous control socket is the reclaude prompt-delivery path and exists
81
+ // ONLY for harnesses that own one (claude) — codex has no such daemon, so it's omitted there.
82
+ const parts = [`SPEXCODE_SESSION_ID=${id}`]
83
+ if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
84
+ for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
85
+ return parts.join(' ')
86
+ }
87
+
88
+ // the prompt-dispatch outcome type + its claude/codex delivery implementations live in the [[harness-adapter]]
89
+ // (each harness OWNS its input channel — claude the rendezvous socket, codex app-server JSON-RPC). Re-exported here
90
+ // for the existing importers (client.ts) that read it off the sessions module.
91
+ export type { DispatchResult }
92
+
93
+ export type Lifecycle = 'active' | 'idle' | 'awaiting' | 'parked' | 'error' | 'asking' | 'queued'
94
+ export type Proposal = 'merge' | 'nothing' | 'close'
95
+ export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued'
96
+ // liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
97
+ // authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
98
+ // override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
99
+ export type Liveness = 'online' | 'starting' | 'offline'
100
+ const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
101
+
102
+ export type Session = {
103
+ id: string; node: string | null; title: string | null; name: string | null; branch: string | null; path: string
104
+ harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
105
+ lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
106
+ prompt: string | null; promptPreview: string | null; created: number; activity: string | null
107
+ sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
108
+ }
109
+
110
+ // ensure a session's GLOBAL store dir exists, returning its path. Idempotent (recursive mkdir) — every
111
+ // writer that drops an artifact (record/prompt/launch/launch.sh/comms) calls this first so order never matters.
112
+ function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
113
+
114
+ // @@@ originating prompt - what the session was ASKED to do, captured at launch so a manager (human or
115
+ // agent) can later answer "what was this session for?" WITHOUT transcript archaeology. Prompts are
116
+ // multi-line, so they live as their own artifact (`prompt`) in the session's GLOBAL store dir (keyed by
117
+ // session_id, [[state]]), never in the worktree. Everything here is BEST-EFFORT: a missing artifact (a
118
+ // session launched before this existed) just means no prompt is shown — never an error, never blocks a launch.
119
+ function writePromptFile(id: string, prompt: string): void {
120
+ try { writeFileSync(join(storeDir(id), 'prompt'), prompt) } catch { /* best-effort; must never block the launch */ }
121
+ }
122
+ function readPromptFile(id: string): string | null {
123
+ try {
124
+ const p = sessionArtifactPath(id, 'prompt')
125
+ if (!existsSync(p)) return null
126
+ const s = readFileSync(p, 'utf8')
127
+ return s.trim() ? s : null
128
+ } catch { return null }
129
+ }
130
+ // @@@ deferred launch prompt - a QUEUED session is a fully-prepared worktree we have NOT launched claude
131
+ // into yet. The exact prompt to launch it with — the directive-generated finish-the-op prompt, or the plain
132
+ // human prompt — is parked as the `launch` artifact in the store dir so the drainer can launch it later
133
+ // (possibly after a backend restart) WITHOUT re-deriving anything. CONSUMED (removed) the moment the session
134
+ // launches, so it exists only while the session waits in the queue. Distinct from `prompt` (the originating ask).
135
+ function writeLaunchFile(id: string, prompt: string): void {
136
+ try { writeFileSync(join(storeDir(id), 'launch'), prompt) } catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
137
+ }
138
+ function readLaunchFile(id: string): string | null {
139
+ try { const p = sessionArtifactPath(id, 'launch'); return existsSync(p) ? readFileSync(p, 'utf8') : null } catch { return null }
140
+ }
141
+ function removeLaunchFile(id: string): void {
142
+ try { rmSync(sessionArtifactPath(id, 'launch'), { force: true }) } catch { /* best-effort */ }
143
+ }
144
+
145
+ // a one-line preview of the originating prompt for tables/events: first non-empty line, truncated.
146
+ function promptPreview(prompt: string, n = 60): string {
147
+ const first = prompt.split('\n').map((l) => l.trim()).find(Boolean) || ''
148
+ return first.length > n ? first.slice(0, n - 1) + '…' : first
149
+ }
150
+
151
+ // the STABLE human label for a session row: a user-chosen NAME (the rename override) wins over everything;
152
+ // else the spec node it references, else a prompt-derived title (node-agnostic sessions), else the branch,
153
+ // else the id. Stable across turns — used for tables/selectors. The frontend mirrors this (session.js
154
+ // sessionName).
155
+ export const sessionLabel = (s: Session): string => s.name || s.node || s.title || s.branch || s.id
156
+
157
+ // @@@ sessionHeadline - the cross-surface HEADLINE: the SAME chain the board card shows (frontend session.js
158
+ // `sessionHeadline`). A user-chosen NAME wins, else the worker's LIVE self-summary (`activity`, the Claude
159
+ // Code pane title — see [[session-activity]]), else a fuller prompt preview, else node/title/branch/id. Use
160
+ // it wherever a session is NAMED FOR A HUMAN in CROSS-SESSION comms (the reply-channel footer, the watch
161
+ // greeting), so an agent recognises a peer the way it reads the board — NOT the bare 7-word prompt
162
+ // truncation `title` that `sessionLabel` stops at. `sessionLabel` stays the stable name for tables/selectors.
163
+ export const sessionHeadline = (s: Session): string => s.name || s.activity || s.promptPreview || s.node || s.title || s.branch || s.id
164
+
165
+ async function tmux(args: string[]): Promise<string> {
166
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8' })
167
+ return stdout
168
+ }
169
+ async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
170
+ export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
171
+
172
+ // worktrees + branches are created off MAIN even when the server runs inside a worktree.
173
+ function mainRoot(): string {
174
+ try { return dirname(gitCommonDir()) }
175
+ catch { return repoRoot() }
176
+ }
177
+
178
+ // @@@ pkgRoot - the CLI package's OWN directory, derived from this module's location, never a hardcoded
179
+ // repoRoot()+'spec-cli'. This file lives at <pkgRoot>/src/sessions.ts, so `..` from it is the package
180
+ // root — making the launch-script paths (hooks/, node_modules/.bin/tsx, src/cli.ts) survive the package
181
+ // being renamed or relocated out of the default <repo>/spec-cli layout.
182
+ function pkgRoot(): string {
183
+ return fileURLToPath(new URL('..', import.meta.url))
184
+ }
185
+
186
+ // the in-memory session record — the typed view of session.json. `governed` (dashboard-launched=true vs
187
+ // user-self-launched=false), `worktreePath`/`branch`/`createdAt` are the fields the board USED to read off
188
+ // the worktree (its path/birthtime); now they live IN the record, since the record is the enumeration source.
189
+ // `name` is the rename override (distinct from the prompt-derived `title`); `session` is the harness session_id
190
+ // (the store key). The launcher mints the id (`claude --session-id <id>`) so it equals what every hook payload
191
+ // and CLAUDE_CODE_SESSION_ID carry — one id across the record dir, tmux window, rendezvous socket, and commits.
192
+ type SessRec = {
193
+ session: string; governed: boolean; worktreePath: string; branch: string | null
194
+ node: string | null; title: string | null; name: string | null
195
+ status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
196
+ sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
197
+ }
198
+ const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
199
+ const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
200
+
201
+ // typed read of a session's record from the global store (null if it has none — a self-launched session that
202
+ // only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
203
+ // readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
204
+ // on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
205
+ function readRecord(id: string): SessRec | null {
206
+ const raw = readAliasedRawRecord(id)
207
+ if (!raw) return null
208
+ return fromRaw(raw)
209
+ }
210
+ function fromRaw(raw: RawRecord): SessRec {
211
+ const status = LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
212
+ const proposal = raw.proposal && PROPOSALS.has(raw.proposal as Proposal) ? raw.proposal as Proposal : null
213
+ const sk = raw.sortkey
214
+ const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
215
+ return {
216
+ session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
217
+ node: raw.node || null, title: raw.title || null, name: raw.name || null,
218
+ status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
219
+ harness: raw.harness || 'claude', // records written before the harness field default to claude
220
+ harnessSessionId: raw.harness_session_id || null,
221
+ }
222
+ }
223
+ // @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
224
+ // present (nulls rendered as "" / the empty value, never an absent key). That stable shape is the contract the
225
+ // pure-shell hot-path hook (mark-active) relies on: it value-replaces `"status"`/`"proposal"`/`"note"` with a
226
+ // single sed and never needs jq on the user's box. So do NOT switch to conditional keys or a compact dump.
227
+ function writeRecord(rec: SessRec): void {
228
+ const obj = {
229
+ session_id: rec.session,
230
+ governed: rec.governed,
231
+ worktree_path: rec.worktreePath,
232
+ branch: rec.branch ?? '',
233
+ node: rec.node ?? '',
234
+ title: rec.title ?? '',
235
+ name: rec.name ?? '',
236
+ status: rec.status,
237
+ proposal: rec.proposal ?? '',
238
+ merges: rec.merges,
239
+ note: rec.note ?? '',
240
+ sortkey: rec.sortKey ?? '',
241
+ createdAt: rec.createdAt,
242
+ harness: rec.harness || 'claude',
243
+ harness_session_id: rec.harnessSessionId ?? '',
244
+ }
245
+ mkdirSync(sessionStoreDir(rec.session), { recursive: true })
246
+ writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
247
+ }
248
+
249
+ // @@@ fail-loud enumeration - the worktree set is the board's EXISTENCE truth, so a failed enumeration must
250
+ // NEVER masquerade as an empty repo. `gitA` swallows a git error to '' (→ zero rows), which a caller would
251
+ // read as "every worktree was removed" — exactly the false mass-`closed` watchSessions would emit once the
252
+ // flicker debounce is gone. `git worktree list` ALWAYS lists at least the main worktree, so an ok run with
253
+ // zero `worktree ` lines is itself a failure. Both cases THROW; the caller (listSessions) propagates and
254
+ // watchSessions' poll `catch` simply skips the tick with `prev` intact — no fabricated removals.
255
+ async function listWorktrees(): Promise<{ path: string; branch: string | null }[]> {
256
+ const r = await gitTry(['-C', mainRoot(), 'worktree', 'list', '--porcelain'])
257
+ if (!r.ok) throw new Error(`git worktree list failed: ${r.stderr.trim() || 'unknown error'}`)
258
+ const list: { path: string; branch: string | null }[] = []
259
+ let cur: { path: string; branch: string | null } | null = null
260
+ for (const line of r.stdout.split('\n')) {
261
+ if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null }; list.push(cur) }
262
+ else if (line.startsWith('branch ') && cur) cur.branch = line.slice(7).replace('refs/heads/', '')
263
+ }
264
+ if (!list.length) throw new Error('git worktree list returned no worktrees (enumeration failed; the main worktree is always present)')
265
+ return list
266
+ }
267
+
268
+ // @@@ reconcile - the shown status. awaiting → the proposal's label (review/done/close-pending),
269
+ // shown regardless of liveness. active/idle → their LIVENESS: offline if no tmux for the recorded id OR
270
+ // claude's rendezvous socket is gone (claude exited), else idle if the idle_prompt hook has fired since
271
+ // the last tool use, else working.
272
+
273
+ // @@@ liveTmux - which of OUR tmux sessions exist, in ONE tmux call. reconcile used to spawn two tmux per
274
+ // session (has-session + display-message), so listing N sessions was 2N spawns — the dominant /api/sessions
275
+ // cost under multi-agent load. `tmux list-sessions` returns every session on our socket at once; a session
276
+ // present in this set has a live tmux window (session_name = the id we created it with). tmux server down /
277
+ // no sessions → empty set → everything reconciles to offline, which is correct. We deliberately do NOT read
278
+ // `pane_current_command` any more: workers launch through the `reclaude` wrapper, which runs claude as a
279
+ // CHILD rather than exec'ing it, so the pane's foreground command is the wrapper/shell even while claude is
280
+ // very much alive — the pane command is NOT a liveness signal. claude liveness is its rendezvous socket
281
+ // (see reconcile). The per-session alive() above stays for the single-session ops (capture / rawKey).
282
+ async function liveTmux(): Promise<Set<string>> {
283
+ const s = new Set<string>()
284
+ let out = ''
285
+ try { out = await tmux(['list-sessions', '-F', '#{session_name}']) } catch { return s }
286
+ for (const line of out.split('\n')) { const name = line.trim(); if (name) s.add(name) }
287
+ return s
288
+ }
289
+
290
+ // @@@ paneTitles - every session pane's RAW tmux title, free from tmux. The worker launches one pane per
291
+ // session, named with the session id, so ONE `list-panes -a` maps id → its raw `#{pane_title}`. Same shape
292
+ // and cost as liveTmux (one tmux call for the whole list); failure → empty map, so a tmux hiccup just drops
293
+ // the subtitle for a tick, never the session. The raw title is NOT yet a headline — what a pane title MEANS
294
+ // is harness-specific (claude: a self-authored task summary; codex: a spinner + the cwd folder name), so the
295
+ // id→harness gating + glyph parse happens per session in paneActivity, not here.
296
+ async function paneTitles(): Promise<Map<string, string>> {
297
+ const m = new Map<string, string>()
298
+ let out = ''
299
+ try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}']) } catch { return m }
300
+ for (const line of out.split('\n')) {
301
+ const tab = line.indexOf('\t'); if (tab < 0) continue
302
+ const id = line.slice(0, tab), title = line.slice(tab + 1)
303
+ if (id && title) m.set(id, title)
304
+ }
305
+ return m
306
+ }
307
+
308
+ // @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
309
+ // NOT become) a session's headline activity. The board headline derives from the pane title ONLY for a
310
+ // harness whose pane title is its own task self-summary (`paneTitleIsSelfSummary`, an adapter capability —
311
+ // [[harness-adapter]]). claude qualifies (it writes its task summary into the OSC title), so we parse it with
312
+ // selfSummary (glyph-gated). codex does NOT — its pane title is a spinner glyph + the cwd FOLDER name, so
313
+ // returning it would headline the worktree folder, not the task; we refuse it (→ null) and sessionHeadline
314
+ // falls through to promptPreview (the launch prompt). The ONLY harness branch is the capability read here —
315
+ // no `if (codex)`, no glyph special-case; selfSummary stays the pure claude-title parser.
316
+ export function paneActivity(harness: Harness, paneTitle: string | null | undefined): string | null {
317
+ if (paneTitle == null || !harness.paneTitleIsSelfSummary) return null
318
+ return selfSummary(paneTitle)
319
+ }
320
+
321
+ // @@@ selfSummary - the agent's OWN live one-line description, parsed from its tmux pane title — the SINGLE
322
+ // place the "is this the agent speaking?" rule lives, exported so it is unit-auditable. Claude Code sets that
323
+ // title via an OSC escape and ALWAYS leads it with a status glyph: ✳ (and its ✶✻✽✢ blink frames) when idle, a
324
+ // braille spinner frame (U+2800–U+28FF) while working. That leading glyph is the only reliable proof the
325
+ // title is the agent and not tmux's default — which, from pane birth until the first turn, is the HOST NAME
326
+ // (e.g. `ser581555022561`) or a bare `Claude Code` splash. So the glyph is REQUIRED: no leading glyph → null,
327
+ // and the caller keeps showing the launch-prompt placeholder instead of flickering through the host name and
328
+ // splash. The leading glyph run (with the spaces/`·` between and after) is stripped — the dashboard draws its
329
+ // own status dot, a frozen spinner frame is just noise — leaving only the summary text (null if it is empty).
330
+ // ONE regex is the single source of the glyph rule: it gates (requires ≥1 glyph) and strips in one match.
331
+ export function selfSummary(paneTitle: string): string | null {
332
+ const m = /^[\s·]*(?:[✳✶✻✽✢⠀-⣿][\s·]*)+(.*)$/u.exec(paneTitle)
333
+ return m ? (m[1].trim() || null) : null
334
+ }
335
+
336
+ // @@@ launchedAt - when we last started a tmux window for an id (set in launch()). claude needs ~15-20s
337
+ // after the window appears to recreate its rendezvous socket; in that window the socket is absent but the
338
+ // session is booting, NOT dead. reconcile consults this to report 'starting' (a distinct transient state)
339
+ // instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
340
+ // memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
341
+ const launchedAt = new Map<string, number>()
342
+ const BOOT_GRACE_MS = 25000 // > waitForReady's 15s timeout, covering the observed ~15-20s agent boot window
343
+
344
+ // @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
345
+ // lifecycle, from a prebuilt tmux set (no per-call spawn — see liveTmux) + the rendezvous socket. offline
346
+ // iff the tmux window is gone OR claude's rendezvous socket is absent past the boot window. claude (via the
347
+ // reclaude wrapper) holds CLAUDE_BG_RENDEZVOUS_SOCK open the whole time it is alive, so the socket — NOT
348
+ // pane_current_command, which is the wrapper/shell while claude runs as its child — is the truth it is up.
349
+ // A just-launched agent whose socket hasn't appeared yet reads the transient 'starting' for the grace
350
+ // window; only past it (socket still gone) is it genuinely 'offline'.
351
+ function liveness(rec: SessRec, live: Set<string>): Liveness {
352
+ if (!rec.session) return 'offline'
353
+ // ask the ADAPTER ([[harness-adapter]]): claude = tmux up AND its rendezvous socket present; codex = tmux up,
354
+ // app-server up, AND native thread id captured. The 'starting' grace stays here (a launcher concern): a
355
+ // just-launched agent whose online-signal hasn't appeared yet reads 'starting' for the boot window, only past
356
+ // it 'offline'.
357
+ const h = harnessById(rec.harness || defaultHarness.id)
358
+ if (h.liveness(rec, live.has(rec.session), runtimeRoot()) === 'online') return 'online'
359
+ const at = launchedAt.get(rec.session)
360
+ return at && Date.now() - at < BOOT_GRACE_MS ? 'starting' : 'offline'
361
+ }
362
+
363
+ // reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
364
+ // surfaces ([[state]]), never a third source of truth. Lifecycle wins the label except where liveness must
365
+ // show through: awaiting → its proposal label; parked/error/asking/queued → themselves; active/idle → their
366
+ // liveness (offline/starting), else the active-only idle/working inference (the mark-active hook flips idle
367
+ // → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
368
+ // terminal-mount and the relaunch panel on; this label is for badges and `spex ls`.
369
+ function reconcile(rec: SessRec, live: Set<string>): DisplayStatus {
370
+ if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
371
+ if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
372
+ const lv = liveness(rec, live)
373
+ if (lv !== 'online') return lv // 'offline' | 'starting'
374
+ return rec.status === 'idle' ? 'idle' : 'working'
375
+ }
376
+
377
+ // resolve a session id to its record + worktree. Now a DIRECT store read (the record carries worktree_path),
378
+ // not a scan of every worktree reading its `.session` — O(1) and exact. null when the id has no governed-or-not
379
+ // record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/reopen/merge/close/…) are unchanged.
380
+ async function findWorktree(id: string): Promise<{ path: string; branch: string | null; rec: SessRec } | null> {
381
+ const rec = readRecord(id)
382
+ if (!rec) return null
383
+ return { path: rec.worktreePath, branch: rec.branch, rec }
384
+ }
385
+
386
+ function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
387
+ const prompt = readPromptFile(rec.session) // the originating ask, captured at launch (store artifact; null for old sessions)
388
+ // activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
389
+ // dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
390
+ const showActivity = lv === 'online'
391
+ return { id: rec.session, node: rec.node, title: rec.title, name: rec.name, branch: rec.branch, path: rec.worktreePath, harness: rec.harness, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: prompt ? promptPreview(prompt) : null, created: rec.createdAt, activity: showActivity ? activity : null, sortKey: rec.sortKey }
392
+ }
393
+
394
+ // @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
395
+ // over the derived label (node/title/branch/id) on every surface. Persisted to the worktree's `.session`
396
+ // — the only writer of that file — so the name survives backend restarts and is read back like any other
397
+ // field. A blank name CLEARS the override, reverting the row to its derived label. Works for a session in
398
+ // any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
399
+ // (the route answers 404). The frontend's right-click rename is the sole caller today.
400
+ export async function renameSession(id: string, name: string): Promise<boolean> {
401
+ const wt = await findWorktree(id)
402
+ if (!wt) return false
403
+ writeRecord({ ...wt.rec, name: name.trim() || null })
404
+ return true
405
+ }
406
+
407
+ // @@@ setSessionSort - set (or clear) a session's drag-reorder pseudo-time ([[session-reorder]]), parallel
408
+ // to renameSession: persisted to the session's global record so the manual order survives restarts and
409
+ // shows on every surface (all sort by `sortKey ?? created`). A null key CLEARS it, dropping the row back to
410
+ // its `created` slot. Works in any state since it edits the on-disk record. Unknown id → false (route 404s).
411
+ export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
412
+ const wt = await findWorktree(id)
413
+ if (!wt) return false
414
+ writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
415
+ return true
416
+ }
417
+
418
+ // the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded.
419
+ export async function sessionPrompt(id: string): Promise<string | null> {
420
+ return readRecord(id) ? readPromptFile(id) : null
421
+ }
422
+
423
+ // @@@ lastKnownSession - the last successfully-read Session row per session_id. The record's EXISTENCE in
424
+ // the store is definitive; a transient failure reading it (an ENOENT race, or a sibling read failing under a
425
+ // concurrent merge) must NOT drop the row from the board — that absence is exactly what watchSessions used to
426
+ // mis-read as a `closed · removed`. So a degraded read serves this last-known row instead of vanishing. Pruned
427
+ // each poll to only ids still present.
428
+ const lastKnownSession = new Map<string, Session>()
429
+
430
+ // @@@ listSessions - the board's session list, enumerated from the GLOBAL per-session store (replacing the
431
+ // old `git worktree list` scan). Every GOVERNED record this project owns becomes a row, status reconciled;
432
+ // non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
433
+ // Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
434
+ export async function listSessions(): Promise<Session[]> {
435
+ // ONE store enumeration + ONE tmux liveness snapshot + ONE pane-title snapshot for the whole list (all
436
+ // independent), then every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
437
+ const [ids, live, titles] = await Promise.all([
438
+ Promise.resolve(listSessionIds()), liveTmux(), paneTitles(),
439
+ ])
440
+ const rows = ids.map((id) => guardSession(id, () => {
441
+ const rec = readRecord(id)
442
+ if (!rec || !rec.governed) { lastKnownSession.delete(id); return null } // no record, or a self-launched (non-board) one
443
+ // the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
444
+ // is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
445
+ const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), titles.get(id))
446
+ const s = toSession(rec, reconcile(rec, live), liveness(rec, live), activity)
447
+ lastKnownSession.set(id, s)
448
+ return s
449
+ }, () => {
450
+ // DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
451
+ // session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
452
+ // show yet, it reappears next poll — and since it was never in watchSessions' `prev`, no false closed.)
453
+ return lastKnownSession.get(id) ?? null
454
+ }))
455
+ // prune last-known entries for ids that no longer appear at all (genuinely removed), keeping it bounded.
456
+ const liveIds = new Set(ids)
457
+ for (const k of [...lastKnownSession.keys()]) if (!liveIds.has(k)) lastKnownSession.delete(k)
458
+ // @@@ creation order - order by birth (oldest first): each session keeps its slot for life and a new one
459
+ // simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex ls`).
460
+ // `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
461
+ // overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
462
+ // same-instant births (or sort-keys) stay deterministic.
463
+ return rows.filter((s): s is Session => s != null).sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
464
+ }
465
+
466
+ // a per-session read guard mirroring resilience.guardWorktree but keyed on the store record (not a worktree
467
+ // path): run `primary`; if it throws AND the record dir still exists, the failure is transient → serve the
468
+ // `degraded` fallback; if the dir is gone (a genuine close), return null (omit). No async git, so it's sync.
469
+ function guardSession(id: string, primary: () => Session | null, degraded: () => Session | null): Session | null {
470
+ try { return primary() }
471
+ catch { return existsSync(sessionStoreDir(id)) ? degraded() : null }
472
+ }
473
+
474
+ // @@@ session graph = LIVE monitors, not a stored relationship. An edge A→B means "agent A is RIGHT NOW
475
+ // running `spex watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
476
+ // persisted subscription. When a `spex watch` process starts it registers here and heartbeats; the edge
477
+ // exists ONLY while that watch runs (deregistered on exit, dropped on a missed heartbeat). Single owner:
478
+ // this in-memory map in the SERVER process — the watch process (a separate `spex watch`) talks to it over
479
+ // HTTP (POST /api/sessions/graph/watch + …/unwatch). No datastore, no file: a backend restart starts
480
+ // empty and live watches re-register on their next heartbeat. Kept isolated from the board assembler.
481
+ // an edge is either a LIVE monitor arrow (A→B = A watches B, directed) or a recorded comms link (A↔B =
482
+ // they have exchanged `count` direct messages, undirected). The dashboard renders the two kinds apart.
483
+ export type Edge = { from: string; to: string; kind: 'monitor' | 'comms'; count?: number }
484
+
485
+ // @@@ comms log - direct agent talk ([[comms-edge]]), recorded per-worktree. `spex session send` goes
486
+ // THROUGH the backend (sendKeys); on a delivered message that carries a sender, the backend appends one
487
+ // {peer, ts} line to the RECIPIENT's comms log — each message counted exactly once, on the side the backend
488
+ // already resolved. Persisted (survives a backend restart, unlike the in-memory monitor registrations) and
489
+ // untracked, in the session's GLOBAL store dir (`comms.ndjson`, keyed by session_id) — it dies with the
490
+ // session record, matching a graph of LIVE sessions. No sender → not logged. Best-effort: a recording failure
491
+ // must NEVER fail the delivered message.
492
+ function commsLog(id: string): string { return sessionArtifactPath(id, 'comms.ndjson') }
493
+ async function recordComms(toId: string, fromId: string): Promise<void> {
494
+ if (!fromId || fromId === toId) return
495
+ try {
496
+ if (!readRecord(toId)) return
497
+ appendFileSync(join(storeDir(toId), 'comms.ndjson'), JSON.stringify({ peer: fromId, ts: new Date().toISOString() }) + '\n')
498
+ } catch { /* a recording failure must not fail the delivered send */ }
499
+ }
500
+ // the peers this session has exchanged messages with — one entry per message, newest appended last.
501
+ function readComms(id: string): string[] {
502
+ try {
503
+ const path = commsLog(id)
504
+ if (!existsSync(path)) return []
505
+ return readFileSync(path, 'utf8').split('\n').filter(Boolean)
506
+ .map((l) => { try { return String(JSON.parse(l).peer || '') } catch { return '' } }).filter(Boolean)
507
+ } catch { return [] }
508
+ }
509
+ // keyed by an opaque per-watch token (one per `spex watch` process), so a single agent may run several
510
+ // monitors without them clobbering each other. `selectors` is what the watch targets (resolved LIVE at
511
+ // read time, not frozen here); empty / @all = a GLOBAL watcher. `expires` is the heartbeat backstop.
512
+ type WatchReg = { watcher: string; selectors: string[]; expires: number }
513
+ const watches = new Map<string, WatchReg>()
514
+ const DEFAULT_WATCH_TTL_MS = 15000
515
+ // register OR heartbeat a live monitor. watcher = the watching agent's OWN session id; ttlMs = how long
516
+ // this stays live without another beat. Returns false on a bad pair (the route answers 400).
517
+ export function registerWatch(token: string, watcher: string, selectors: string[], ttlMs = DEFAULT_WATCH_TTL_MS): boolean {
518
+ if (!token || !watcher) return false
519
+ watches.set(token, { watcher, selectors: selectors.filter(Boolean), expires: Date.now() + Math.max(1000, ttlMs) })
520
+ return true
521
+ }
522
+ // deregister a watch (its `spex watch` exited); false if the token wasn't registered.
523
+ export function deregisterWatch(token: string): boolean { return watches.delete(token) }
524
+ // the still-live registrations, pruning any whose heartbeat lapsed — the backstop for a watch that died
525
+ // without a clean unwatch (SIGKILL, a dropped connection, a backend that was down at exit time).
526
+ function liveWatches(): WatchReg[] {
527
+ const now = Date.now()
528
+ const out: WatchReg[] = []
529
+ for (const [token, reg] of watches) {
530
+ if (reg.expires <= now) watches.delete(token)
531
+ else out.push(reg)
532
+ }
533
+ return out
534
+ }
535
+ // the graph: live sessions as nodes; edges DERIVED from live monitor registrations. Edge A→B = watcher A
536
+ // is currently watching B. Selectors are resolved LIVE here via selectSessions (the same matcher `spex
537
+ // ls/watch` use), so a global (@all/empty) watcher links to every CURRENT session — incl. ones launched
538
+ // after the watch started — and a node/branch selector picks up future matches too. Self-edges and edges
539
+ // touching a non-live session are dropped; duplicate A→B (two watches over the same pair) collapse to one.
540
+ export async function sessionGraph(): Promise<{ nodes: Session[]; edges: Edge[] }> {
541
+ const nodes = await listSessions()
542
+ const live = new Set(nodes.map((s) => s.id))
543
+ const edges: Edge[] = []
544
+ const seen = new Set<string>()
545
+ for (const reg of liveWatches()) {
546
+ if (!live.has(reg.watcher)) continue // the watching agent itself is gone
547
+ for (const t of selectSessions(nodes, reg.selectors)) {
548
+ if (t.id === reg.watcher) continue
549
+ const key = `${reg.watcher} ${t.id}`
550
+ if (seen.has(key)) continue
551
+ seen.add(key)
552
+ edges.push({ from: reg.watcher, to: t.id, kind: 'monitor' })
553
+ }
554
+ }
555
+ // comms edges: undirected direct-talk, one per pair, carrying the message count — read from each live
556
+ // session's per-worktree log and aggregated by sorted pair so A→B and B→A fold into one A↔B count. An
557
+ // edge to a non-live session is dropped, like the monitor edges.
558
+ const commsCount = new Map<string, number>()
559
+ for (const n of nodes) {
560
+ for (const peer of readComms(n.id)) {
561
+ if (peer === n.id || !live.has(peer)) continue
562
+ const key = n.id < peer ? `${n.id}\t${peer}` : `${peer}\t${n.id}`
563
+ commsCount.set(key, (commsCount.get(key) ?? 0) + 1)
564
+ }
565
+ }
566
+ for (const [key, count] of commsCount) {
567
+ const [from, to] = key.split('\t')
568
+ edges.push({ from, to, kind: 'comms', count })
569
+ }
570
+ return { nodes, edges }
571
+ }
572
+
573
+ // @@@ watch registration (CLIENT side) - a `spex watch` process is separate from the server, so it
574
+ // REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
575
+ // deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
576
+ // streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
577
+ export const apiBase = () => process.env.SPEXCODE_API_URL || `http://127.0.0.1:${process.env.PORT || 8787}`
578
+ // the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
579
+ // Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
580
+ // re-exported here so callers keep one name. Used by `spex watch` + the agent-typed `spex session …`
581
+ // declarations; the hooks instead pass `--session <id>` from the payload, so they never depend on this.
582
+ export const ownSessionId = envSessionId
583
+
584
+ // @@@ withSenderHint - bidirectional agent messaging. `spex session send` delivers a prompt to the
585
+ // recipient; this stamps WHO sent it and HOW to reply as a one-line insert appended to the delivered
586
+ // message, so the recipient agent CAN reply (or ignore) and the reply rides the SAME send back into the
587
+ // sender's prompt — a reply channel, no workflow enforcement, just a prompt insert. The sender is the
588
+ // SENDING agent's OWN session (id from [[dispatch]]'s send-command process via ownSessionId, `label` its
589
+ // board HEADLINE — sessionHeadline, the same title the recipient reads on the board); its FULL id is stamped
590
+ // so the reply addresses exactly one session, never a prefix. A human running `send` from a plain shell has
591
+ // no session id (sender=null) → the bare message, no hint, no loop.
592
+ // @@@ delimited as a SESSION TITLE - the headline is wrapped `session "<headline>" (<id>)` so the recipient
593
+ // reads it AS a session title, not as prose bleeding into the message (an un-delimited prompt-derived title
594
+ // was unrecognisable as a name). A bare-id label (no better name in the chain) needs no quotes.
595
+ export type MsgSender = { id: string; label: string | null }
596
+ export function withSenderHint(text: string, sender: MsgSender | null): string {
597
+ if (!sender) return text
598
+ const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
599
+ return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
600
+ }
601
+ async function postJSON(path: string, body: unknown): Promise<void> {
602
+ try {
603
+ await fetch(`${apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
604
+ } catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
605
+ }
606
+ export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
607
+ postJSON('/api/sessions/graph/watch', { token, watcher, selectors, ttlMs })
608
+ export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/graph/unwatch', { token })
609
+
610
+ // @@@ isBackendDown - a `client.ts` BackendError surfacing in the watch poll loop (whose session
611
+ // `source` is the HTTP backend client). Matched by NAME, not `instanceof`, so sessions.ts never imports
612
+ // client.ts at runtime (client.ts imports apiBase from here — a runtime import back would be a cycle). A
613
+ // backend-down poll must NOT be swallowed as a transient git/tmux hiccup: watch warns ONCE and keeps
614
+ // streaming rather than emitting false `closed` events for every session.
615
+ export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.name === 'BackendError'
616
+
617
+ const slugify = (s: string | null) => (s || 'session').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session'
618
+
619
+ // @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
620
+ // "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
621
+ // actually left in the prompt (changed it, or deleted it for a node-agnostic prompt) is the truth. We read
622
+ // the FIRST `@<id>` that begins a word (same positional rule the dashboard's mention menu uses). When there
623
+ // is none, the session is node-agnostic and we label it by the first few words of the prompt instead.
624
+ // The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
625
+ // (`.config`) keeps the dot — without `\.?` here `@.config` captures nothing and never resolves to a node.
626
+ const MENTION = /(?:^|\s)@(\.?[A-Za-z0-9_-]+)/
627
+ const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
628
+ function titleFromPrompt(prompt: string): string | null {
629
+ const first = (prompt || '').trim().split('\n')[0].trim()
630
+ const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
631
+ if (!words) return null
632
+ return words.length > 50 ? words.slice(0, 49).trimEnd() + '…' : words
633
+ }
634
+
635
+ // @@@ launchScript - the WHOLE launch invocation (rendezvous env prefix + harness command + the human prompt)
636
+ // is written to an ephemeral `launch.sh` in the session's GLOBAL store and
637
+ // run via `bash <file>`, NOT typed inline. Inline send-keys TRUNCATES past ~2KB (the launch-prompt-limit trap),
638
+ // and a long human prompt + spec pointer can exceed it; a file has no length limit
639
+ // and the only thing send-keys types is the short `bash <file>` line. It's the SAME command the inline path
640
+ // ran (env prefix exports the rendezvous vars to the claude child), just relocated to a file. Liveness no
641
+ // longer cares what the pane's foreground command is: claude runs as a child of bash (and, via the
642
+ // `reclaude` wrapper, a grandchild), so the pane command is the wrapper/shell — reconcile reads claude's
643
+ // rendezvous socket instead (present while claude is alive, gone once it exits). The file lives OUTSIDE the
644
+ // worktree (in the store, keyed by session_id), so it never pollutes the spec/code work.
645
+ function launchScript(id: string, tail: string, harness: Harness = HARNESS): string {
646
+ const file = join(storeDir(id), 'launch.sh')
647
+ // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
648
+ // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
649
+ // agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
650
+ writeFileSync(file, `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot())} ${tail}\n`)
651
+ return file
652
+ }
653
+ async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS): Promise<void> {
654
+ await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path])
655
+ await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness)}`])
656
+ await tmux(['send-keys', '-t', id, 'Enter'])
657
+ launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
658
+ }
659
+
660
+ // @@@ node directives - a dashboard board chord (nn / dd) prefixes the New Session prompt with a
661
+ // structured op the server PERFORMS in the fresh worktree before the agent starts, then hands the agent
662
+ // a prompt to finish it intelligently. The directive is anchored at the prompt start and carries an
663
+ // @<target>, so it's unambiguous and wins over the plain first-@ mention. `rest` is the human's own text
664
+ // after it (what they want the new node to be, or why the node is going away). No directive → the prompt
665
+ // is an ordinary session prompt and nothing is mutated.
666
+ // @new under @<parentId>: <describe the node> → create a placeholder child, agent names+specs+codes it
667
+ // @delete @<nodeId>: <why / guidance> → remove the node's dir, agent refactors per git history
668
+ type Directive = { kind: 'new'; targetId: string; rest: string } | { kind: 'delete'; targetId: string; rest: string }
669
+ const NEW_OP = /^\s*@new\b[^\n@]*@([A-Za-z0-9_-]+)\s*:?\s*/i
670
+ const DEL_OP = /^\s*@delete\b[^\n@]*@([A-Za-z0-9_-]+)\s*:?\s*/i
671
+ function parseDirective(prompt: string): Directive | null {
672
+ let m = prompt.match(NEW_OP); if (m) return { kind: 'new', targetId: m[1], rest: prompt.slice(m[0].length).trim() }
673
+ m = prompt.match(DEL_OP); if (m) return { kind: 'delete', targetId: m[1], rest: prompt.slice(m[0].length).trim() }
674
+ return null
675
+ }
676
+
677
+ // find a spec node's directory inside a worktree's .spec tree (id = dir basename, the node-identity rule).
678
+ function findNodeDir(specRoot: string, nodeId: string): string | null {
679
+ if (!existsSync(specRoot)) return null
680
+ const stack = [specRoot]
681
+ while (stack.length) {
682
+ const dir = stack.pop()!
683
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
684
+ if (!e.isDirectory()) continue
685
+ const child = join(dir, e.name)
686
+ if (e.name === nodeId && existsSync(join(child, 'spec.md'))) return child
687
+ stack.push(child)
688
+ }
689
+ }
690
+ return null
691
+ }
692
+
693
+ // a lint-clean placeholder spec.md: minimal valid frontmatter + a two-part body, NO `code:` list (an
694
+ // empty governed-files list keeps `spex lint` integrity at 0 errors). The agent replaces it wholesale.
695
+ function placeholderSpec(id: string, sessionId: string): string {
696
+ return [
697
+ '---', `title: ${id}`, 'status: pending', 'hue: 210',
698
+ 'desc: placeholder — to be named and specified by the dispatched session.',
699
+ `session: ${sessionId}`, '---', `# ${id}`, '',
700
+ '## raw source', '',
701
+ 'Placeholder node. The dispatched session replaces this with the real human intent, renames the',
702
+ 'directory to a proper id, and writes the matching spec and code.', '',
703
+ '## expanded spec', '',
704
+ 'Pending — authored by the dispatched session.', '',
705
+ ].join('\n')
706
+ }
707
+ // create the placeholder child under <parentId> (or the .spec root if the parent isn't in this worktree).
708
+ // returns the new spec.md path relative to the worktree, for the agent prompt.
709
+ function createPlaceholder(wtPath: string, parentId: string, placeholderId: string, sessionId: string): string {
710
+ const specRoot = join(wtPath, '.spec')
711
+ const parentDir = findNodeDir(specRoot, parentId) || specRoot
712
+ const dir = join(parentDir, placeholderId)
713
+ mkdirSync(dir, { recursive: true })
714
+ writeFileSync(join(dir, 'spec.md'), placeholderSpec(placeholderId, sessionId))
715
+ return relative(wtPath, join(dir, 'spec.md'))
716
+ }
717
+ // remove a node's whole directory (its subtree). Returns the deleted spec.md's worktree-relative path
718
+ // (so the agent can `git log --follow` it), or null when the node isn't present in this worktree.
719
+ function removeNode(wtPath: string, nodeId: string): string | null {
720
+ const dir = findNodeDir(join(wtPath, '.spec'), nodeId)
721
+ if (!dir) return null
722
+ const rel = relative(wtPath, join(dir, 'spec.md'))
723
+ rmSync(dir, { recursive: true, force: true })
724
+ return rel
725
+ }
726
+
727
+ // @@@ directive prompts - the INTENT handed to the dispatched agent. The server did the mechanical
728
+ // spec-tree mutation; the agent does the intelligent rest (name + spec + code, or history-driven
729
+ // refactor). Like mergePrompt, the op is a DISPATCH: the server never authors specs or refactors code.
730
+ // These state only the TASK — they deliberately do NOT restate the git flow's mechanics (commit format, the
731
+ // Session: trailer, the node-branch flow, the merge style). Those are carried by product MECHANISM, not a
732
+ // dispatch string: newSession makes the branch, the prepare-commit-msg hook stamps the trailer, the
733
+ // `core/spec` system contract (gathered into appendSysArg) demands commit-before-declare, and mergePrompt
734
+ // states the merge style at merge time. The only handoff detail kept here is "propose merge, don't merge
735
+ // yourself" (the human triggers
736
+ // the merge later, see mergePrompt).
737
+ function newNodePrompt(placeholderId: string, parentId: string, relPath: string, rest: string): string {
738
+ return `A placeholder spec node \`${placeholderId}\` was created under parent \`${parentId}\` at ${relPath} in this worktree. ` +
739
+ `Turn it into a real node and build it, per this request:\n\n${rest || '(no extra description — infer the intent from the parent and the codebase)'}\n\n` +
740
+ `1. Choose a good kebab-case id reflecting the intent (node id = its directory basename) and \`git mv\` the directory \`${dirname(relPath)}\` to it, keeping it under \`${parentId}\`. ` +
741
+ `2. Rewrite spec.md at contract altitude: real title/desc, the two-part body (raw source = human intent · expanded spec = behavioral contract), and a \`code:\` list of the files it will govern. ` +
742
+ `3. Implement the code the spec describes. 4. Keep \`spex lint\` at 0 errors and the build green. ` +
743
+ `When it's ready, propose merge for the human to review — do NOT merge it yourself.`
744
+ }
745
+ function deleteNodePrompt(nodeId: string, relPath: string | null, rest: string): string {
746
+ const recover = relPath
747
+ ? `Recover what it was: \`git log --follow -- ${relPath}\` then \`git show\` the relevant commits to read its old spec and the \`code:\` files it governed.`
748
+ : `The node's spec.md wasn't found in the tree; recover what \`${nodeId}\` was from git history (\`git log\` / \`git show\`).`
749
+ return `The spec node \`${nodeId}\` has been intentionally DELETED (its directory removed) in this worktree. ` +
750
+ `Make the codebase consistent without it, per this request:\n\n${rest || '(no extra guidance — use your judgement)'}\n\n` +
751
+ `1. ${recover} ` +
752
+ `2. Decide what happens to that governed code now the spec is gone — remove it, fold it into another node's responsibility, or re-point references — and fix any specs that linked \`[[${nodeId}]]\`. ` +
753
+ `3. Apply the refactor; keep \`spex lint\` at 0 errors and the build green. ` +
754
+ `When it's ready, propose merge for the human to review — do NOT merge it yourself.`
755
+ }
756
+
757
+ // @@@ concurrency cap + queue - keep at most maxActive() agents AUTONOMOUSLY PROGRESSING at once. A slot is
758
+ // COMPUTE pressure, so only an agent actually consuming it holds one: genuinely live (tmux window + rendezvous
759
+ // socket present) AND either churning (`working`) or paused-to-self-resume (`parked`). Every state that is
760
+ // WAITING ON THE HUMAN frees its slot — `idle` (stopped at its prompt), `asking` (asked a question), and the
761
+ // proposal states (review/done/close-pending) — exactly as `offline`/`queued` do. Those agents burn no
762
+ // compute, so they must NEVER block a fresh launch: the old rule counted them, so a pile of "waiting on you"
763
+ // sessions wedged the queue while the box sat near-idle (the reported blockage). Liveness is still checked
764
+ // directly (the socket truth reconcile uses), so an authored `parked` whose claude has since died does NOT
765
+ // pin a slot. The cap throttles concurrent COMPUTE; everything waiting-on-you waits cheap as a live pane.
766
+ const OCCUPIES_SLOT = new Set<DisplayStatus>(['working', 'parked', 'starting']) // starting's boot window is also held via `launching`
767
+ function isOccupying(s: Session, live: Set<string>): boolean {
768
+ if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
769
+ const rec = readRecord(s.id)
770
+ if (!rec) return false
771
+ return harnessById(rec.harness || defaultHarness.id).liveness(rec, live.has(rec.session), runtimeRoot()) === 'online' // and only while the agent is genuinely live (its adapter's channel)
772
+ }
773
+ // sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
774
+ // `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
775
+ // over-launch and blow past the cap. We hold the slot here from launch until the agent is online (waitForReady)
776
+ // or it times out.
777
+ // In-memory in the single server process (the only drainer) — lost on restart, which is fine: a restart drains
778
+ // the durable `queued` worktrees fresh with nothing in flight.
779
+ const launching = new Set<string>()
780
+ let draining = false // re-entrancy guard: only one drain pass runs at a time (no double-launch)
781
+
782
+ // launch a prepared `queued` worktree: feed it its parked launch prompt, flip it to active. Returns false
783
+ // (leaving it queued, to be retried next drain) if the worktree/prompt is gone or the tmux launch threw.
784
+ async function startQueued(id: string): Promise<boolean> {
785
+ const wt = await findWorktree(id)
786
+ if (!wt) return false
787
+ const launchPrompt = readLaunchFile(id)
788
+ if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
789
+ launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
790
+ const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
791
+ try {
792
+ const sq = `'${launchPrompt.replace(/'/g, `'\\''`)}'`
793
+ await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h)
794
+ } catch {
795
+ launching.delete(id)
796
+ return false // launch failed → stays `queued`, retried on the next drain tick
797
+ }
798
+ writeRecord({ ...wt.rec, status: 'active', proposal: null })
799
+ removeLaunchFile(id) // consumed
800
+ // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
801
+ // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
802
+ void waitForReady(id, h).finally(() => launching.delete(id))
803
+ return true
804
+ }
805
+
806
+ // @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
807
+ // re-entrancy-guarded; safe to call on every slot-freeing event (newSession / close / propose) AND on a
808
+ // periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
809
+ // (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
810
+ // so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
811
+ export async function drainQueue(): Promise<void> {
812
+ if (draining) return
813
+ draining = true
814
+ try {
815
+ const cap = maxActive() // read once per drain pass (spexcode.json → env → 6); won't shift mid-burst
816
+ for (;;) {
817
+ const [sessions, live] = await Promise.all([listSessions(), liveTmux()])
818
+ const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, live) ? 1 : 0), 0)
819
+ if (occupied >= cap) break
820
+ const next = sessions.find((s) => s.status === 'queued' && !launching.has(s.id))
821
+ if (!next) break
822
+ if (!(await startQueued(next.id))) break // launch failed → stop this pass; a later tick retries
823
+ }
824
+ } finally { draining = false }
825
+ }
826
+
827
+ // @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
828
+ // newSession/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
829
+ // going parked writes its .session from a hook subprocess the server never sees, and a crash just makes a
830
+ // socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
831
+ // and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
832
+ let supervisingQueue = false
833
+ export function superviseQueue(intervalMs = 3000): void {
834
+ if (supervisingQueue) return
835
+ supervisingQueue = true
836
+ const tick = async () => {
837
+ try { await drainQueue() } catch { /* transient git/tmux hiccup; next tick retries */ }
838
+ setTimeout(tick, intervalMs)
839
+ }
840
+ void tick()
841
+ }
842
+
843
+ // @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
844
+ // BACKEND's process, not the caller's. The backend owns the launch env (notably SPEXCODE_CLAUDE_CMD, which
845
+ // reclaude strips from agent envs) AND the concurrency cap. An agent that runs `spex new` (e.g. a supervisor)
846
+ // has a stripped env, so an in-process launch would spawn workers under plain `claude` and 401 at boot. So
847
+ // the CLI POSTs to the running backend whenever one answers, making the backend the single owner of session
848
+ // launching. Only when NO backend is reachable do we fall back to launching in this process (with a stderr
849
+ // warning) — the backend's own POST handler calls newSession directly, so it never re-enters this path.
850
+ export async function createSession(node: string | null, prompt: string, harness: string = defaultHarness.id): Promise<Session> {
851
+ let res: Response
852
+ try {
853
+ res = await fetch(`${apiBase()}/api/sessions`, {
854
+ method: 'POST',
855
+ headers: { 'content-type': 'application/json' },
856
+ body: JSON.stringify({ node, prompt, harness }),
857
+ })
858
+ } catch {
859
+ console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
860
+ return newSession(node, prompt, harness)
861
+ }
862
+ if (!res.ok) throw new Error(`backend rejected session (${res.status}): ${await res.text().catch(() => '')}`)
863
+ return await res.json() as Session
864
+ }
865
+
866
+ // @@@ newSession - durable worktree (branch node/<slug> off main) + .session label. The agent does NOT
867
+ // launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
868
+ // immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
869
+ // `spex session new`. A board directive (nn/dd) additionally mutates the worktree's spec tree up front and
870
+ // hands the agent a finish-the-op prompt.
871
+ export async function newSession(node: string | null, prompt: string, harness: string = defaultHarness.id): Promise<Session> {
872
+ const id = randomUUID()
873
+ const h = harnessById(harness) // throws on an unknown id — fail loud, never silently launch the wrong harness
874
+ const directive = parseDirective(prompt)
875
+ // node identity + label: a delete targets an existing node (link it); a new op has no id yet so it's
876
+ // labeled by the human's text; otherwise explicit --node wins, else the prompt's first @-mention.
877
+ const ref = directive?.kind === 'delete' ? directive.targetId
878
+ : directive?.kind === 'new' ? null
879
+ : (node || mentionedNode(prompt))
880
+ const title = ref ? null : titleFromPrompt(directive?.rest ?? prompt)
881
+ const slug = `${slugify(ref || title || (directive ? `${directive.kind}-node` : null))}-${id.slice(0, 4)}`
882
+ const branch = `node/${slug}`
883
+ const path = join(mainRoot(), '.worktrees', slug)
884
+ await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
885
+ // prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
886
+ // slot is free, else it waits — durable as a global record (+ its worktree), so it survives a backend
887
+ // restart and is still findable. governed:true — this is a DASHBOARD/CLI-launched session, so it feeds the
888
+ // board and the lifecycle hooks act on it; worktreePath/branch/createdAt are stamped here (the record, not
889
+ // the worktree, is the board's enumeration source now).
890
+ const rec: SessRec = {
891
+ session: id, governed: true, worktreePath: path, branch,
892
+ node: ref || null, title, name: null, status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
893
+ harness: h.id, harnessSessionId: null,
894
+ }
895
+ writeRecord(rec)
896
+ writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
897
+ // render the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
898
+ // shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
899
+ // self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
900
+ // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
901
+ // own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
902
+ try { materialize(path) } catch { /* best-effort; the dispatch.sh gate re-renders on the first event anyway */ }
903
+ // perform the directive's spec-tree mutation in the worktree, then PARK the finish-the-op prompt for launch.
904
+ // the mutation is uncommitted, so the board's overlay shows it instantly (added ghost / deleted mark) even
905
+ // while the session only sits queued.
906
+ let launchPrompt = prompt
907
+ if (directive?.kind === 'new') {
908
+ const placeholderId = `untitled-${id.slice(0, 4)}`
909
+ const relPath = createPlaceholder(path, directive.targetId, placeholderId, id)
910
+ launchPrompt = newNodePrompt(placeholderId, directive.targetId, relPath, directive.rest)
911
+ } else if (directive?.kind === 'delete') {
912
+ launchPrompt = deleteNodePrompt(directive.targetId, removeNode(path, directive.targetId), directive.rest)
913
+ } else if (ref) {
914
+ // @@@ spec pointer - the ref (explicit --node, else the prompt's first @mention) named an EXISTING node.
915
+ // Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
916
+ // it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
917
+ // is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
918
+ // a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
919
+ const spec = (await loadSpecs()).find((n) => n.id === ref)
920
+ if (spec) launchPrompt = `${prompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
921
+ }
922
+ writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
923
+ await drainQueue() // launch now if under the cap, else leave it queued for a free slot
924
+ const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
925
+ // queued → no process yet (offline liveness); just-launched → its socket is still booting (starting).
926
+ const queued = after.status === 'queued'
927
+ return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
928
+ }
929
+
930
+ // @@@ waitForReady - after a launch/relaunch, the agent needs SEVERAL SECONDS to come up; launch() only TYPES
931
+ // the start line via send-keys and returns immediately, so the agent's online-signal does not exist yet on
932
+ // return. Poll the ADAPTER's liveness ([[harness-adapter]]) at a small interval up to a bounded timeout so the
933
+ // agent counts as "ready" only once it is genuinely online — claude: its rendezvous socket up; codex: its
934
+ // project app-server socket up AND native thread id captured — then a follow-on dispatch (merge / send) lands
935
+ // in a LIVE agent instead of racing the boot and failing loud on a session that is actually recovering.
936
+ // BOUNDED + fail-loud preserved: a
937
+ // genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
938
+ // deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
939
+ const SOCKET_READY_TIMEOUT_MS = 15000
940
+ const SOCKET_POLL_MS = 200
941
+ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<boolean> {
942
+ const deadline = Date.now() + timeoutMs
943
+ for (;;) {
944
+ const rec = readRecord(id)
945
+ if (rec && harness.liveness(rec, await alive(id), runtimeRoot()) === 'online') return true
946
+ if (Date.now() >= deadline) return false
947
+ await new Promise((r) => setTimeout(r, SOCKET_POLL_MS))
948
+ }
949
+ }
950
+
951
+ // @@@ reopen - "back to working": clear any proposal → active, then ONE relaunch path. The agent needs
952
+ // (re)starting iff it isn't running for this id — the SAME deterministic liveness the adapter computes
953
+ // ([[harness-adapter]]): claude offline = no tmux OR no rendezvous socket (claude exited, even though the
954
+ // wrapper/shell may still hold the pane); codex offline = no tmux, no project app-server, or no captured native
955
+ // thread id. When it IS offline we drop any stale pane and launch a fresh window through the adapter's resumeArg
956
+ // — claude `--resume <id>` (the SAME conversation), codex `resume <thread-id>` once captured, else a fresh TUI
957
+ // in the same worktree/record. Then we WAIT for the
958
+ // agent to come online (waitForReady) before returning, so a caller that dispatches immediately after reopen
959
+ // (e.g. mergeSession) addresses a LIVE agent rather than racing the boot. If it's still live we only cleared
960
+ // the proposal — no wait. Also serves the plain "relaunch" of an offline (already-active) one. Fail-loud is
961
+ // unchanged: if the agent never comes online, the later deliver() fails loud.
962
+ export async function reopen(id: string): Promise<boolean> {
963
+ const wt = await findWorktree(id)
964
+ if (!wt) return false
965
+ const h = harnessById(wt.rec.harness || defaultHarness.id)
966
+ writeRecord({ ...wt.rec, status: 'active', proposal: null })
967
+ if (h.liveness(wt.rec, await alive(id), runtimeRoot()) !== 'online') {
968
+ await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane if any (no-op when none)
969
+ await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h)
970
+ await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
971
+ }
972
+ return true
973
+ }
974
+
975
+ // agent/human PROPOSAL → awaiting (review = propose merge, done = nothing, close-pending = propose close).
976
+ export async function propose(id: string, proposal: Proposal): Promise<boolean> {
977
+ const wt = await findWorktree(id)
978
+ if (!wt) return false
979
+ writeRecord({ ...wt.rec, status: 'awaiting', proposal })
980
+ void drainQueue() // a proposal frees this session's slot — start the next queued one if any
981
+ return true
982
+ }
983
+ // @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
984
+ // authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
985
+ // SOMETHING changed, not the transition, so they force a write, never infer. The session it writes is resolved
986
+ // by id: `sessionId` (the hooks pass `--session <id>` from the payload) wins, else ownSessionId() (the env var
987
+ // the agent's own `spex session …` carries). Unknown id / no record → false (the route/CLI reports it).
988
+ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
989
+ const id = opts.sessionId || ownSessionId()
990
+ if (!id) return false
991
+ const rec = readRecord(id)
992
+ if (!rec) return false
993
+ writeRecord({
994
+ ...rec, status,
995
+ proposal: status === 'awaiting' ? (opts.proposal ?? 'nothing') : null,
996
+ note: opts.note ?? null,
997
+ })
998
+ return true
999
+ }
1000
+ export const markDone = (proposal: Proposal = 'nothing', sessionId?: string) => markState('awaiting', { proposal, sessionId })
1001
+ export const markError = (sessionId?: string) => markState('error', { sessionId })
1002
+ export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
1003
+ const id = sessionId || ownSessionId()
1004
+ if (!id || !harnessSessionId) return false
1005
+ const rec = readRecord(id)
1006
+ if (!rec) return false
1007
+ writeRecord({ ...rec, harnessSessionId })
1008
+ return true
1009
+ }
1010
+ // @@@ markIdle - the ONE INFERRED state, so (unlike the agent-authored writers above) it carries a strict
1011
+ // active-only guard: the Notification(idle_prompt) hook fires it when claude is waiting at its prompt, and it
1012
+ // may ONLY overwrite `active` → `idle`. A deliberate declaration (awaiting / asking / parked / error) must
1013
+ // survive — idle only fills the gap where the agent stopped WITHOUT declaring (e.g. an API error killed the
1014
+ // turn before the Stop gate). The mark-active hook flips idle → active on resume. Same id resolution as markState.
1015
+ export function markIdle(sessionId?: string): boolean {
1016
+ const id = sessionId || ownSessionId()
1017
+ if (!id) return false
1018
+ const rec = readRecord(id)
1019
+ if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
1020
+ writeRecord({ ...rec, status: 'idle' })
1021
+ return true
1022
+ }
1023
+ // @@@ asking has TWO writers, both deterministic (neither guarded active-only): (1) the mark-active
1024
+ // PreToolUse hook captures it the instant the agent invokes the AskUserQuestion tool (status=asking,
1025
+ // the question as the note) — a HARD signal that the agent is asking the human; (2) the agent declares it
1026
+ // itself via markState('asking', { note }) — `spex session ask`, e.g. at the Stop gate. Either
1027
+ // way the mark-active path clears it back to active on the next tool / prompt, same as any non-active state.
1028
+
1029
+ // @@@ mergeReadiness - the deterministic commit gate the Stop hook enforces before a session may declare
1030
+ // done / propose merge. The dogfood ritual lands every change as a COMMIT on the node branch first, so two
1031
+ // states block a declaration: (1) any uncommitted working-tree change, or (2) 0 commits ahead of main
1032
+ // (nothing committed to merge). Since the global-store refactor, SpexCode writes NO per-session files into
1033
+ // the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are gitignored
1034
+ // (the materialize shims/skills) or tracked-and-committed (the contract block in CLAUDE.md/AGENTS.md), so
1035
+ // neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
1036
+ // work, no runtime-file filtering needed.
1037
+ // Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
1038
+ // can't misdirect repo discovery to the cwd (the same trap git.ts documents). `main` resolves via the shared
1039
+ // refs, so `main..HEAD` works from any linked worktree regardless of where main is checked out.
1040
+ export function mergeReadiness(): { ready: boolean; reason?: string } {
1041
+ let dirty: string[] = []
1042
+ try {
1043
+ dirty = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean).map(porcelainPath)
1044
+ } catch { /* git status failed — fall through to the ahead check, still a real guard */ }
1045
+ if (dirty.length) {
1046
+ const shown = dirty.slice(0, 8).join(', ') + (dirty.length > 8 ? ', …' : '')
1047
+ return { ready: false, reason: `uncommitted changes on your node branch (${shown}) — commit your spec+code first` }
1048
+ }
1049
+ let ahead = 0
1050
+ const base = mainBranch()
1051
+ try { ahead = Number(git(['rev-list', '--count', `${base}..HEAD`]).trim()) || 0 } catch { ahead = 0 }
1052
+ if (ahead === 0) return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge` }
1053
+ return { ready: true }
1054
+ }
1055
+
1056
+ // the path a `git status --porcelain` line refers to: strip the `XY ` status, and for a rename keep the
1057
+ // NEW path (after ` -> `). Shared by the dirty-file counters (mergeReadiness above, reviewPayload below).
1058
+ function porcelainPath(line: string): string {
1059
+ let p = line.slice(3)
1060
+ const arrow = p.indexOf(' -> '); if (arrow >= 0) p = p.slice(arrow + 4)
1061
+ return p
1062
+ }
1063
+
1064
+ // @@@ MANAGER COCKPIT - the review payload (the cockpit's first verb; see the manager-cockpit spec node).
1065
+ // One server-side bundle that lets a manager (human or agent) decide whether to merge a session WITHOUT
1066
+ // hand-running git: how far ahead it is, its REAL changes (merge-base diff, never a phantom main..HEAD one),
1067
+ // whether uncommitted non-runtime work remains, the merge/typecheck/lint gates, and the agent's standing
1068
+ // proposal. ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); typecheck and
1069
+ // lint reflect the CLI package's OWN location (where this runs) — the spec-cli that's actually live. null
1070
+ // when no session has that id.
1071
+ export type ReviewGates = {
1072
+ conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
1073
+ typecheck: { ok: boolean; errorCount: number } // `tsc --noEmit` on the CLI package
1074
+ lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
1075
+ }
1076
+ export type ReviewPayload = {
1077
+ id: string; node: string | null; branch: string | null
1078
+ ahead: number // commits the node branch is ahead of main
1079
+ dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
1080
+ diff: ReviewDiffFile[] // the worker's real changes, anchored at the merge-base
1081
+ gates: ReviewGates
1082
+ proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
1083
+ }
1084
+
1085
+ // @@@ typecheckPkg - `tsc --noEmit` on the CLI package at its OWN location (pkgRoot — never a hardcoded
1086
+ // path), using the tsc binary from that package's node_modules. errorCount counts `error TSxxxx` lines; ok
1087
+ // is the exit status. If tsc can't be spawned at all (no node_modules) it resolves ok:false / 0 errors — a
1088
+ // loud "couldn't typecheck" rather than a false green.
1089
+ function typecheckPkg(): Promise<{ ok: boolean; errorCount: number }> {
1090
+ const root = pkgRoot()
1091
+ const tsc = join(root, 'node_modules', '.bin', 'tsc')
1092
+ return new Promise((resolve) => {
1093
+ execFile(tsc, ['--noEmit'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 24 }, (err, stdout) => {
1094
+ const out = (stdout || '') + (err && (err as unknown as { stdout?: string }).stdout || '')
1095
+ resolve({ ok: !err, errorCount: (out.match(/error TS\d+/g) || []).length })
1096
+ })
1097
+ })
1098
+ }
1099
+
1100
+ // @@@ reviewPayload - assemble the cockpit review for one session. The five session-specific reads
1101
+ // (ahead / dirty / diff / conflict gate) plus the two location gates (typecheck / lint) are all
1102
+ // independent, so they run in parallel. lint is the existing spec-lint module run in-process (it reports
1103
+ // over this process's repo — the CLI package's own tree).
1104
+ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1105
+ const wt = await findWorktree(id)
1106
+ if (!wt) return null
1107
+ const { specLint } = await import('./lint.js')
1108
+ const base = mainBranch()
1109
+ const [aheadOut, statusOut, diff, conflictsWithMain, typecheck, findings] = await Promise.all([
1110
+ gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
1111
+ gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
1112
+ mergeBaseDiff(wt.path, base),
1113
+ mergeConflicts(wt.path, base),
1114
+ typecheckPkg(),
1115
+ specLint(),
1116
+ ])
1117
+ // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
1118
+ // path is genuine work — this is just the total uncommitted count.
1119
+ const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
1120
+ return {
1121
+ id, node: wt.rec.node, branch: wt.branch,
1122
+ ahead: Number(aheadOut.trim()) || 0,
1123
+ dirtyNonRuntime, diff,
1124
+ gates: {
1125
+ conflictsWithMain, typecheck,
1126
+ lint: {
1127
+ errorCount: findings.filter((f) => f.level === 'error').length,
1128
+ warningCount: findings.filter((f) => f.level === 'warn').length,
1129
+ },
1130
+ },
1131
+ proposal: { kind: wt.rec.proposal, note: wt.rec.note },
1132
+ }
1133
+ }
1134
+
1135
+ // @@@ mergePrompt - the human's merge INTENT, handed to the session's OWN agent. Merge is a DISPATCH, not a
1136
+ // server git script: the agent knows the work, so IT runs the merge, resolves any conflicts, and VERIFIES the
1137
+ // outcome — the guarantee lives in that verification, never a server-side gate. This is also the ONE place the
1138
+ // merge STYLE is stated (no other mechanism carries it): a --no-ff merge commit `merge <branch>: <reason>`
1139
+ // into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree).
1140
+ // After a clean merge the branch is 0 ahead of main, so the agent proposes CLOSE — not merge (the commit gate
1141
+ // would block a merge proposal; propose-close is exempt) — and the human confirms the close.
1142
+ function mergePrompt(mainPath: string, branch: string, reason: string): string {
1143
+ const base = mainBranch()
1144
+ return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself.\n\n` +
1145
+ `1. Merge from the main checkout with a no-ff merge commit:\n git -C ${mainPath} merge --no-ff -m "merge ${branch}: ${reason}" ${branch}\n` +
1146
+ `2. If it conflicts, resolve the conflicts (you know the intent) and complete the merge commit. ` +
1147
+ `3. Verify it landed: \`${base}\`'s HEAD must now be the new merge commit and no merge may be left in progress — if anything went half-merged, run \`git -C ${mainPath} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
1148
+ `4. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
1149
+ }
1150
+
1151
+ // @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
1152
+ // SESSION'S OWN agent lands the merge, never the server (it carries no `git merge` logic and never touches
1153
+ // main's tree). It reopens the session (clears the proposal → active, `--resume`s via reopen if tmux died —
1154
+ // which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
1155
+ // through sendKeys. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
1156
+ // the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
1157
+ // CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
1158
+ // re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
1159
+ export async function mergeSession(id: string): Promise<{ dispatched: boolean; reason?: string }> {
1160
+ const wt = await findWorktree(id)
1161
+ if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
1162
+ const branch = wt.branch, main = mainRoot()
1163
+ if (!(await reopen(id))) return { dispatched: false, reason: 'could not reopen session' }
1164
+ const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
1165
+ const reason = subject.replace(/^spec:\s+/, '') || branch
1166
+ const r = await sendKeys(id, mergePrompt(main, branch, reason))
1167
+ if (!r.ok) return { dispatched: false, reason: r.error }
1168
+ return { dispatched: true }
1169
+ }
1170
+
1171
+ // @@@ stopAgentProcess - the shared teardown both exit and close begin with, so there is ONE kill path, not
1172
+ // two: kill the agent's tmux client, drop its boot-window stamp (else a just-launched id lingers in the grace
1173
+ // window reading `starting` instead of `offline`), and sweep its rendezvous socket. The socket lives in the OS
1174
+ // tmpdir (NOT the worktree), so worktree removal alone would leave it behind — closing many sessions over time
1175
+ // would accumulate stale `spexcode-rv-*.sock` files; we unlink it here (force = no error if claude/OS already
1176
+ // removed it). Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
1177
+ async function stopAgentProcess(id: string): Promise<void> {
1178
+ await tmuxOk(['kill-session', '-t', id])
1179
+ launchedAt.delete(id)
1180
+ try { rmSync(rvSock(id), { force: true }) } catch { /* best-effort sweep; tmpdir socket, claude/OS may already be gone */ }
1181
+ }
1182
+
1183
+ // @@@ exitSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
1184
+ // worktree + branch + transcript intact. The session stays on the board, now reading `offline` (no tmux window)
1185
+ // whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation (see reopen). This is
1186
+ // "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
1187
+ // the freed capacity drains a queued session next (drainQueue).
1188
+ export async function exitSession(id: string): Promise<boolean> {
1189
+ const wt = await findWorktree(id)
1190
+ await stopAgentProcess(id)
1191
+ void drainQueue() // an exit frees a slot — start the next queued session if any
1192
+ return !!wt
1193
+ }
1194
+
1195
+ // @@@ closeSession - the REMOVAL (human-confirmed): exit's soft stop PLUS removing the worktree + branch AND
1196
+ // the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
1197
+ // exitSession (no duplicate kill path), then the git worktree/branch teardown that exit deliberately skips,
1198
+ // then the store sweep (exit KEEPS the record so the session stays on the board offline; close discards it).
1199
+ export async function closeSession(id: string): Promise<boolean> {
1200
+ const wt = await findWorktree(id)
1201
+ await stopAgentProcess(id)
1202
+ if (wt) {
1203
+ await gitA(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
1204
+ if (wt.branch) await gitA(['-C', mainRoot(), 'branch', '-D', wt.branch])
1205
+ }
1206
+ try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* best-effort sweep of the global record */ }
1207
+ void drainQueue() // a close frees a slot — start the next queued session if any
1208
+ return !!wt
1209
+ }
1210
+
1211
+ // @@@ captureSessionResult - the session's live pane as a one-shot snapshot (output), the server side of
1212
+ // `GET /api/sessions/:id/capture` that `spex capture` (a backend client) reads. A monitoring read MUST
1213
+ // distinguish "I failed to read" from "the pane is genuinely empty" — the old captureSession collapsed
1214
+ // unknown-id, offline, and capture-error all to `''`, indistinguishable from an empty pane (a blank screen
1215
+ // that exits 0 is worse than useless to a manager). So the result is DISCRIMINATED: an empty pane is a
1216
+ // legitimate `{ok:true, pane:''}`; the three failure modes carry distinct reasons the route maps to distinct
1217
+ // HTTP codes (unknown→404, offline→409, capture-failed→502). The known-vs-offline check only runs on the
1218
+ // cold/not-alive branch, so a live capture (the polled hot path) costs just the one capture-pane.
1219
+ export type CaptureResult = { ok: true; pane: string } | { ok: false; reason: 'unknown' | 'offline' | 'capture-failed' }
1220
+ export async function captureSessionResult(id: string): Promise<CaptureResult> {
1221
+ if (!(await alive(id))) {
1222
+ const known = (await listSessions()).some((s) => s.id === id)
1223
+ return { ok: false, reason: known ? 'offline' : 'unknown' }
1224
+ }
1225
+ try { return { ok: true, pane: await tmux(['capture-pane', '-e', '-p', '-t', id]) } }
1226
+ catch { return { ok: false, reason: 'capture-failed' } }
1227
+ }
1228
+
1229
+ // @@@ watch - the event source for Claude Code's Monitor tool (first-class managing-agent support).
1230
+ // Polls the session list and emits the COMPLETE session lifecycle so it's a true "subscribe to all
1231
+ // session changes" feed: a LAUNCH (first sighting of an id, even though it enters at 'working', which is
1232
+ // not actionable — emitted ONCE per id so a manager learns a new session started), each ACTIONABLE state
1233
+ // transition — review / done / close-pending (agent proposals), offline (process died), error — and the
1234
+ // removal. Per Monitor's "silence is not success" rule a vanished session pings too. Net feed:
1235
+ // launched → [actionable transitions] → closed. Each line names the suggested next action(s). Drop into Monitor:
1236
+ // Monitor({ command: 'spex watch', persistent: true, description: 'spex session state changes' })
1237
+ // @@@ presentation + selection - shared by `spex ls` (pretty), `spex watch` (events) and the API.
1238
+ export const STATUS_GLYPH: Record<DisplayStatus, string> = {
1239
+ working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
1240
+ 'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc',
1241
+ }
1242
+ const ANSI: Record<DisplayStatus, string> = {
1243
+ working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90',
1244
+ }
1245
+
1246
+ // @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
1247
+ // selector matches a session iff it is the session's full id, an id-PREFIX, its node, or its branch. This is
1248
+ // the single predicate; selectSessions (MANY) and resolveSession (ONE) both call it, so id-prefix/node/branch
1249
+ // resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
1250
+ // review/merge/send/close act on".
1251
+ export function matchesSelector(s: Session, q: string): boolean {
1252
+ return s.id === q || s.id.startsWith(q) || s.node === q || s.branch === q
1253
+ }
1254
+
1255
+ // no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
1256
+ export function selectSessions(all: Session[], selectors: string[], statuses?: string[]): Session[] {
1257
+ let out = all
1258
+ const sel = selectors.filter((x) => x && x !== '@all')
1259
+ if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q)))
1260
+ if (statuses && statuses.length) out = out.filter((s) => statuses.includes(s.status))
1261
+ return out
1262
+ }
1263
+
1264
+ // @@@ resolveSession - resolve ONE selector to ONE session against a board: the single-target counterpart of
1265
+ // selectSessions, for the control verbs (review/send/merge/close/reopen/capture/prompt). The backend matches
1266
+ // ids EXACTLY, so a verb resolves the selector here first and then calls with the FULL id — a node/branch/
1267
+ // prefix selector drives a verb just as it filters `ls`. The result is DISCRIMINATED so a caller can fail
1268
+ // precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
1269
+ // otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
1270
+ export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
1271
+ export function resolveSession(selector: string, sessions: Session[]): Resolved {
1272
+ const exact = sessions.find((s) => s.id === selector)
1273
+ if (exact) return { ok: exact }
1274
+ const hits = sessions.filter((s) => matchesSelector(s, selector))
1275
+ if (hits.length === 1) return { ok: hits[0] }
1276
+ return hits.length ? { ambiguous: hits } : { none: true }
1277
+ }
1278
+
1279
+ const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s)
1280
+ // short display label per status (only close-pending differs from the status name) \u2014 used by the legend.
1281
+ const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
1282
+
1283
+ // @@@ statusLegend - one-line glyph\u2192meaning key, BUILT from STATUS_GLYPH so it can never drift from
1284
+ // the glyphs the table actually prints. Shown under `spex ls` so the symbols are self-explanatory.
1285
+ export function statusLegend(color = true): string {
1286
+ const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
1287
+ const parts = (Object.keys(STATUS_GLYPH) as DisplayStatus[]).map(
1288
+ (k) => `${c(ANSI[k], STATUS_GLYPH[k])} ${SHORT[k] || k}`,
1289
+ )
1290
+ return c('90', ' key: ') + parts.join(' ')
1291
+ }
1292
+
1293
+ // human-friendly aligned table: header + (glyph + colour + status + name + id + merges + note) rows +
1294
+ // a status legend, so the table tells the whole story (incl. each agent's note) at a glance.
1295
+ export function formatTable(sessions: Session[], color = true): string {
1296
+ const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
1297
+ if (!sessions.length) return c('90', ' no living sessions')
1298
+ const header = c('90', ` ${'STATUS'.padEnd(13)} ${'NODE'.padEnd(22)} ${'ID'.padEnd(8)} ${'\u00d7'.padEnd(4)}${'PROMPT'.padEnd(42)}NOTE`)
1299
+ const rows = sessions.map((s) => {
1300
+ const g = STATUS_GLYPH[s.status] ?? '\u00b7'
1301
+ const code = ANSI[s.status] ?? '0'
1302
+ const name = sessionLabel(s).slice(0, 22).padEnd(22)
1303
+ const st = s.status.padEnd(13)
1304
+ const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
1305
+ const prompt = c('90', (s.promptPreview ? trunc(s.promptPreview, 40) : '').padEnd(42)) // what it was asked to do
1306
+ const note = s.note ? c('90', trunc(s.note, 50)) : ''
1307
+ return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
1308
+ })
1309
+ return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
1310
+ }
1311
+
1312
+ const WATCH_ACTIONABLE = new Set<DisplayStatus>(['review', 'done', 'close-pending', 'offline', 'error', 'asking'])
1313
+ const NEXT: Record<string, string> = {
1314
+ review: 'merge | reopen(back-to-working) | close',
1315
+ done: 'merge | reopen | close',
1316
+ 'close-pending': 'close | reopen',
1317
+ offline: 'reopen (relaunch & resume)',
1318
+ error: 'reopen (relaunch & retry) | capture | close',
1319
+ asking: 'send "<msg>" | capture',
1320
+ idle: 'send "<msg>" | capture',
1321
+ queued: 'waiting for a free slot — starts automatically | close',
1322
+ }
1323
+ export function sessionEvent(s: Session): string {
1324
+ const note = s.note ? ` — note: ${s.note}` : ''
1325
+ const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
1326
+ return `[spex] ${s.status} · ${sessionLabel(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
1327
+ }
1328
+ // @@@ launchEvent - a session's FIRST sighting. A launch goes straight to 'working' (not actionable), so
1329
+ // without this the watch feed would be blind to new sessions starting. Emitted ONCE per id, regardless of
1330
+ // status, so `spex watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
1331
+ export function launchEvent(s: Session): string {
1332
+ const note = s.note ? ` — note: ${s.note}` : ''
1333
+ const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
1334
+ return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
1335
+ }
1336
+ // @@@ source - the session board the poll reads. The CLI passes the BACKEND CLIENT (client.ts
1337
+ // clientListSessions), so `spex watch` streams whatever backend SPEXCODE_API_URL points at — including a
1338
+ // REMOTE machine's. It is REQUIRED (no local default): a forgotten source must be a compile error, never a
1339
+ // silent in-process read of the wrong (local) board — the exact false-green the 2-machine test guards.
1340
+ export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number } }
1341
+ // @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex wait` runs on); a
1342
+ // plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
1343
+ // "block for a worker, then exit" that is GUARANTEED to return. The deadline is checked EVERY poll, before
1344
+ // EVERY sleep (and even when a poll throws), so a target stuck in ANY non-actionable state
1345
+ // (`working`/`parked`/`idle`/`queued`/`starting`) can never hang the caller — it exits at the deadline.
1346
+ // `reached` = the target hit an actionable status; the rest are the loud exits.
1347
+ export type WatchOutcome = { reached: DisplayStatus } | { timedOut: true } | { gone: true } | { backendDown: string }
1348
+ export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
1349
+ const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
1350
+ const tag = as ? `[${as}] ` : ''
1351
+ const prev = new Map<string, DisplayStatus>()
1352
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
1353
+ // the no-hang wall: a fixed deadline computed ONCE, checked unconditionally every iteration below.
1354
+ const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
1355
+ const isActionable = (st: DisplayStatus) => WATCH_ACTIONABLE.has(st) || (includeIdle && st === 'idle')
1356
+ let warnedDown = false
1357
+ for (;;) {
1358
+ try {
1359
+ // EXISTENCE is the selector-matched board across ALL statuses — listSessions now lists every worktree
1360
+ // that exists (a transient detail-read failure degrades a row, never drops it — see guardWorktree), so
1361
+ // membership here IS the worktree's existence. The `statuses` filter governs only which TRANSITIONS we
1362
+ // emit, never whether a session is present — using it for presence would read a status change out of the
1363
+ // filtered set as a (false) removal.
1364
+ const all = selectSessions(await source(), selectors)
1365
+ warnedDown = false // a successful poll re-arms the down-warning, so a recovered-then-redowned backend warns again
1366
+ const ids = new Set(all.map((s) => s.id))
1367
+ const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
1368
+ for (const s of all) {
1369
+ if (!prev.has(s.id)) emit(tag + launchEvent(s)) // FIRST sighting → launched, any status (incl. 'working'), once
1370
+ if (s.status === prev.get(s.id)) continue // only on transition, not every tick
1371
+ prev.set(s.id, s.status)
1372
+ if (passesStatus(s.status) && (WATCH_ACTIONABLE.has(s.status) || (includeIdle && s.status === 'idle'))) emit(tag + sessionEvent(s))
1373
+ }
1374
+ // @@@ closed = the worktree is GONE. Because listSessions lists every EXISTING worktree (a flaky detail
1375
+ // read degrades, never drops), an id absent from the board means its worktree directory was actually
1376
+ // removed: a DEFINITIVE fact, not a flaky absence. So removal needs no 2-poll debounce / existsSync
1377
+ // re-check; emit `closed` exactly once the moment the id leaves the list.
1378
+ for (const id of [...prev.keys()]) {
1379
+ if (ids.has(id)) continue
1380
+ prev.delete(id)
1381
+ emit(`${tag}[spex] closed \u00b7 removed [id ${id}]`)
1382
+ }
1383
+ // BOUNDED mode (`until`, what `spex wait` runs): return the moment a watched target is actionable; an empty selected set
1384
+ // means the target is gone (absent from the board), which it can never come back from. Both sit inside
1385
+ // the try, after the emit pass, so the caller still saw every transition before we hand control back.
1386
+ if (until) {
1387
+ const hit = all.find((s) => isActionable(s.status))
1388
+ if (hit) return { reached: hit.status }
1389
+ if (!all.length) return { gone: true }
1390
+ }
1391
+ } catch (e) {
1392
+ // a backend-down poll must NOT be swallowed as a transient hiccup AND must NOT emit a false `closed`
1393
+ // for every session: we skip the tick (prev is untouched → no phantom removals) and warn ONCE, loudly,
1394
+ // so a manager sees the stream is blind rather than reading silence as "all sessions fine".
1395
+ if (until && isBackendDown(e)) return { backendDown: (e as Error).message } // a bounded wait fails loud, never a false timeout
1396
+ if (isBackendDown(e) && !warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${(e as Error).message}; retrying every ${intervalMs / 1000}s…`) }
1397
+ }
1398
+ // the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep:
1399
+ // this is what guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE.
1400
+ if (until && Date.now() >= deadline) return { timedOut: true }
1401
+ await sleep(intervalMs)
1402
+ }
1403
+ }
1404
+
1405
+ // @@@ sendKeys - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
1406
+ // ([[harness-adapter]]) — claude the rendezvous control socket (inject + submit + confirm accepted), codex
1407
+ // app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent fallback: a prompt that can't be
1408
+ // delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
1409
+ // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
1410
+ // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
1411
+ // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
1412
+ export async function sendKeys(id: string, text: string, from?: string): Promise<DispatchResult> {
1413
+ if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
1414
+ const rec = readRecord(id)
1415
+ if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
1416
+ const h = harnessById(rec.harness || defaultHarness.id)
1417
+ const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, text)
1418
+ // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
1419
+ // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
1420
+ if (r.ok && from) void recordComms(id, from)
1421
+ return r
1422
+ }
1423
+
1424
+ // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
1425
+ // socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
1426
+ // agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
1427
+ // move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
1428
+ // keystroke-navigation state its input box is replaced by the menu, so the dashboard's nav mode forwards
1429
+ // each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
1430
+ // key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
1431
+ // also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
1432
+ // prefix on the token (e.g. `C-r`, `M-b`, `S-Tab`, `C-M-x`); those are passed to tmux UNescaped so it parses
1433
+ // the combo. One key per call, no socket and no Enter-synthesis — this IS the send-keys channel. False if
1434
+ // the tmux session is gone, or if the token isn't a known base after its prefixes (defends the send-keys arg).
1435
+ const TMUX_KEY: Record<string, string> = {
1436
+ Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right',
1437
+ Enter: 'Enter', Escape: 'Escape', Tab: 'Tab', Space: 'Space', Backspace: 'BSpace',
1438
+ Home: 'Home', End: 'End', Delete: 'DC',
1439
+ }
1440
+ // tmux honours an `S-` (shift) modifier ONLY on these named keys; on Enter/Space/BSpace it would send the
1441
+ // literal text "S-Enter" etc. (and shift is a no-op there anyway), so a stray S- is dropped. Shift+Tab is
1442
+ // the named exception: tmux spells it `BTab` (back-tab → ESC[Z, what Claude Code's mode-cycle reads).
1443
+ const SHIFTABLE = new Set(['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'DC'])
1444
+ export async function rawKey(id: string, key: string): Promise<boolean> {
1445
+ if (!key || !(await alive(id))) return false
1446
+ // peel the optional C-/M-/S- modifier prefixes (each at most once, in any order) off the front; the
1447
+ // remainder is the BASE key. The frontend only ever sends {C-,M-,S-} prefixes + a named key or one char.
1448
+ let rest = key, prefix = ''
1449
+ const seen = new Set<string>()
1450
+ while (rest.length >= 2 && (rest[0] === 'C' || rest[0] === 'M' || rest[0] === 'S') && rest[1] === '-' && !seen.has(rest[0])) {
1451
+ seen.add(rest[0]); prefix += rest.slice(0, 2); rest = rest.slice(2)
1452
+ }
1453
+ const named = TMUX_KEY[rest]
1454
+ if (named) {
1455
+ const noShift = prefix.replace('S-', '') // C-/M- without the shift bit
1456
+ let token: string
1457
+ if (prefix.includes('S-') && named === 'Tab') token = noShift + 'BTab' // Shift+Tab → back-tab
1458
+ else if (prefix.includes('S-') && !SHIFTABLE.has(named)) token = noShift + named // tmux can't carry S- here
1459
+ else token = prefix + named
1460
+ await tmux(['send-keys', '-t', id, token]); return true
1461
+ }
1462
+ if ([...rest].length === 1) {
1463
+ // a single printable char: bare → literal (`-l`, so tmux never reinterprets it as a key name);
1464
+ // modified → hand tmux the `C-`/`M-`/`S-` combo to parse (e.g. `C-a`), which `-l` would defeat.
1465
+ if (prefix) { await tmux(['send-keys', '-t', id, prefix + rest]); return true }
1466
+ await tmux(['send-keys', '-t', id, '-l', '--', rest]); return true
1467
+ }
1468
+ return false
1469
+ }