spexcode 0.1.5 → 0.2.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 (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +60 -19
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -1,12 +1,12 @@
1
1
  import { execFile } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
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'
4
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
5
+ import { join, dirname, relative, isAbsolute } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
8
8
  import { loadSpecs } from './specs.js'
9
- import { defaultHarness, harnessById, rvSock, type Harness, type DispatchResult } from './harness.js'
9
+ import { defaultHarness, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
10
10
  import { materialize } from './materialize.js'
11
11
  import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
12
12
 
@@ -21,9 +21,11 @@ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, ses
21
21
  // lifecycle (authored): active | idle | awaiting | parked | error | asking | queued. `idle` is the ONE
22
22
  // inferred one (the Notification(idle_prompt) hook, guarded active-only so it never clobbers a
23
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.
24
+ // liveness (derived for EVERY session): online | starting | offline | unknown. offline = no tmux for the id,
25
+ // or the harness online-signal (claude's rendezvous socket LISTENER a connect, not the socket
26
+ // FILE) is gone past the boot grace; starting = the boot window; unknown = the tmux probe itself
27
+ // failed (timed out under load) so death is UNPROVEN — render probe-failed, never offline/vanish.
28
+ // reconcile composes the two into the compact DisplayStatus for one-glyph surfaces.
27
29
  // awaiting → the agent's PROPOSAL, awaiting a human:
28
30
  // proposal=merge → shown "review" ("ready, merge me")
29
31
  // proposal=nothing → shown "done" ("finished, your call")
@@ -35,8 +37,9 @@ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, ses
35
37
  // schedule and self-resumes); an asking agent resumes only when a human sends it a prompt.
36
38
  // queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
37
39
  // (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
+ // The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible — nothing
41
+ // auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (reopen)
42
+ // deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
40
43
  //
41
44
  // Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
42
45
  // SPEXCODE_TMUX / SPEXCODE_CLAUDE_CMD override both for tests.
@@ -53,8 +56,9 @@ const COLS = 120, ROWS = 32
53
56
  // cap is QUEUED, not started: it becomes a durable `queued` worktree that the drainer launches the moment a
54
57
  // slot frees (an agent stops working/dies). NOT hardcoded — configured PER PROJECT in `spexcode.json`
55
58
  // (`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
59
+ // spexcode.json → `SPEXCODE_MAX_ACTIVE` env → default 8. Read LIVE (cheap file read) so an edit takes effect
57
60
  // on the next drain tick, no restart. Floored at 1 so a bad value can't wedge the queue to 0.
61
+ const DEFAULT_MAX_ACTIVE = 8
58
62
  function maxActive(): number {
59
63
  let v: number | undefined
60
64
  try {
@@ -62,7 +66,7 @@ function maxActive(): number {
62
66
  if (typeof fromJson === 'number' && Number.isFinite(fromJson)) v = fromJson
63
67
  } catch { /* config unreadable — fall through to env/default */ }
64
68
  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))
69
+ return Math.max(1, Math.floor(v ?? DEFAULT_MAX_ACTIVE))
66
70
  }
67
71
 
68
72
  // the rendezvous control socket path + its prompt-delivery/liveness logic now live in the [[harness-adapter]]
@@ -75,10 +79,12 @@ function maxActive(): number {
75
79
  // an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
76
80
  // ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
77
81
  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
+ // SPEXCODE_SESSION_ID is the governed record id. Claude's harness id is the same value, so hooks and CLI
83
+ // calls can use it directly. Codex cannot trust this env inside the long-lived shared app-server; codex hooks
84
+ // start from the payload thread id and alias through harness_session_id, while the short-lived codex-launch
85
+ // process uses this env only to store the freshly started thread id on the governed record. The CLAUDE_BG
86
+ // rendezvous control socket is the reclaude prompt-delivery path and exists ONLY for harnesses that own one
87
+ // (claude) — codex has no such daemon, so it's omitted there.
82
88
  const parts = [`SPEXCODE_SESSION_ID=${id}`]
83
89
  if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
84
90
  for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
@@ -92,16 +98,23 @@ export type { DispatchResult }
92
98
 
93
99
  export type Lifecycle = 'active' | 'idle' | 'awaiting' | 'parked' | 'error' | 'asking' | 'queued'
94
100
  export type Proposal = 'merge' | 'nothing' | 'close'
95
- export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued'
101
+ export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown'
96
102
  // liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
97
103
  // authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
98
104
  // override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
99
- export type Liveness = 'online' | 'starting' | 'offline'
105
+ // `unknown` = the liveness PROBE ITSELF failed (the tmux snapshot timed out / errored under load), so we
106
+ // CANNOT tell — the row renders probe-failed, NEVER offline/closed and never vanishes (board honesty: a slow
107
+ // box must not masquerade as a graveyard, the failure that drove the mass-restore incident).
108
+ export type Liveness = 'online' | 'starting' | 'offline' | 'unknown'
100
109
  const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
101
110
 
102
111
  export type Session = {
103
- id: string; node: string | null; title: string | null; name: string | null; branch: string | null; path: string
112
+ id: string; node: string | null; branch: string | null; path: string
113
+ label: string; headline: string // the DERIVED display strings ([[session-label]]) — the only names surfaces read
114
+ raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
115
+ parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex new` ran inside another session, else null; the frontend folds a child under it at read time
104
116
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
117
+ launcher: string | null // the named launcher profile this session launched under ([[launcher-select]]); null → an unnamed/zero-config launch. Surfaced so a row can badge WHICH launcher (glyph + name) actually brought the worker up
105
118
  lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
106
119
  prompt: string | null; promptPreview: string | null; created: number; activity: string | null
107
120
  sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
@@ -148,24 +161,40 @@ function promptPreview(prompt: string, n = 60): string {
148
161
  return first.length > n ? first.slice(0, n - 1) + '…' : first
149
162
  }
150
163
 
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' })
164
+ // @@@ session-label the ONE place a session's display strings are derived ([[session-label]]). The raw
165
+ // parts (a user rename `name`, the 7-word prompt truncation `title`) never leave this module at the top
166
+ // level: toSession computes `label` (STABLE: name > node > title > branch > id — tables/selectors) and
167
+ // `headline` (LIVE: name > activity > promptPreview > node > title > branch > id — what a human reads,
168
+ // see [[session-activity]]) and the wire carries THOSE; the parts ride only under `raw` for the few
169
+ // explicit consumers (the rename prefill). A surface that wants a session's name reads s.label/s.headline
170
+ // there is no bare s.title/s.name to reach for, which is the enforcement.
171
+ export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
172
+ r.name || r.node || r.title || r.branch || r.id
173
+ export const deriveHeadline = (r: { name?: string | null; activity?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
174
+ r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
175
+
176
+ // accessors kept for the human-naming call sites (watch/notify/reply-channel): trivially the precomputed
177
+ // wire fields, so every surface — CLI, dashboard, comms — reads the same derivation by construction.
178
+ export const sessionLabel = (s: Session): string => s.label
179
+ export const sessionHeadline = (s: Session): string => s.headline
180
+
181
+ // @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
182
+ // HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
183
+ // the human read as "sessions disappeared". So the liveness/title probes pass a bounded timeout; on expiry
184
+ // execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
185
+ // "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
186
+ const TMUX_PROBE_TIMEOUT_MS = 4000
187
+ async function tmux(args: string[], timeoutMs?: number): Promise<string> {
188
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' as const } : {}) })
167
189
  return stdout
168
190
  }
191
+ // a rejected pexec whose child we KILLED (timeout) vs one that exited cleanly non-zero (e.g. tmux "no server
192
+ // running" when there are genuinely no sessions). Only the former is a PROBE FAILURE (→ unknown); a clean
193
+ // non-zero exit is authoritative (→ everything offline). node sets `killed`/`signal` when it SIGKILLs on timeout.
194
+ function probeTimedOut(e: unknown): boolean {
195
+ const err = e as { killed?: boolean; signal?: string | null; code?: string }
196
+ return err?.killed === true || err?.signal === 'SIGKILL' || err?.code === 'ETIMEDOUT'
197
+ }
169
198
  async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
170
199
  export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
171
200
 
@@ -189,11 +218,14 @@ function pkgRoot(): string {
189
218
  // `name` is the rename override (distinct from the prompt-derived `title`); `session` is the harness session_id
190
219
  // (the store key). The launcher mints the id (`claude --session-id <id>`) so it equals what every hook payload
191
220
  // and CLAUDE_CODE_SESSION_ID carry — one id across the record dir, tmux window, rendezvous socket, and commits.
192
- type SessRec = {
221
+ export type SessRec = {
193
222
  session: string; governed: boolean; worktreePath: string; branch: string | null
194
223
  node: string | null; title: string | null; name: string | null
224
+ parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
195
225
  status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
196
226
  sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
227
+ launcher: string | null // the named launcher profile this session launches under ([[launcher-select]]); null → the unnamed global default (an old record, or a zero-config launch)
228
+ launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
197
229
  }
198
230
  const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
199
231
  const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
@@ -214,10 +246,12 @@ function fromRaw(raw: RawRecord): SessRec {
214
246
  const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
215
247
  return {
216
248
  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,
249
+ node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
218
250
  status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
219
251
  harness: raw.harness || 'claude', // records written before the harness field default to claude
220
252
  harnessSessionId: raw.harness_session_id || null,
253
+ launcher: raw.launcher || null, // records written before launchers → null → the unnamed global resolution
254
+ launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
221
255
  }
222
256
  }
223
257
  // @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
@@ -233,6 +267,7 @@ function writeRecord(rec: SessRec): void {
233
267
  node: rec.node ?? '',
234
268
  title: rec.title ?? '',
235
269
  name: rec.name ?? '',
270
+ parent: rec.parent ?? '',
236
271
  status: rec.status,
237
272
  proposal: rec.proposal ?? '',
238
273
  merges: rec.merges,
@@ -241,6 +276,8 @@ function writeRecord(rec: SessRec): void {
241
276
  createdAt: rec.createdAt,
242
277
  harness: rec.harness || 'claude',
243
278
  harness_session_id: rec.harnessSessionId ?? '',
279
+ launcher: rec.launcher ?? '',
280
+ launch_cmd: rec.launchCmd ?? '',
244
281
  }
245
282
  mkdirSync(sessionStoreDir(rec.session), { recursive: true })
246
283
  writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
@@ -270,21 +307,59 @@ async function listWorktrees(): Promise<{ path: string; branch: string | null }[
270
307
  // claude's rendezvous socket is gone (claude exited), else idle if the idle_prompt hook has fired since
271
308
  // the last tool use, else working.
272
309
 
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>()
310
+ // @@@ liveTmux - which of OUR tmux sessions exist AND each pane's runtime probe, in TWO spawns total (one
311
+ // tmux, one ps) for the WHOLE list. reconcile used to spawn two tmux per session (has-session +
312
+ // display-message), so listing N sessions was 2N spawns the dominant /api/sessions cost under multi-agent
313
+ // load. `tmux list-sessions` returns every session on our socket at once; a session present here has a live
314
+ // tmux window (session_name = the id we created it with), mapped to a PaneProbe: its pane's ROOT pid
315
+ // (`#{pane_pid}`) plus ONE shared whole-box pid→(ppid, comm) table from a single `ps` spawn. tmux server
316
+ // down / no sessions empty map everything reconciles to offline, which is correct. `live.has(id)` = the
317
+ // window presence; `live.get(id)` = the probe, which the CODEX adapter's liveness walks to tell a running TUI
318
+ // (a codex/node process among the pane pid's descendants) from a failed launch that dropped back to the bare
319
+ // shell (see [[harness-adapter]] paneTreeRunsCodex — the pane's FOREGROUND command is `bash`, the launch
320
+ // wrapper, even while the TUI renders, so the foreground name is NOT the signal). CLAUDE ignores the probe —
321
+ // its workers launch through the `reclaude` wrapper, which runs claude as a CHILD, so claude liveness stays
322
+ // its rendezvous socket. The per-session alive() above stays for the single-session ops (capture / rawKey).
323
+ async function procSnapshot(): Promise<ProcTable> {
324
+ const t: ProcTable = new Map()
284
325
  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
326
+ try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: TMUX_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' })) } catch { return t }
327
+ for (const line of out.split('\n')) {
328
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
329
+ if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
330
+ }
331
+ return t
332
+ }
333
+ // @@@ LiveSnap - the ONE liveness snapshot the whole session list shares. `windows` = our live tmux windows
334
+ // (id → PaneProbe) + one whole-box process table; `sockets` = the ids whose rendezvous socket has a LIVE
335
+ // LISTENER (connect-probed once here, not the file-exists lie — [[harness-adapter]]); `probeFailed` = the tmux
336
+ // window probe itself FAILED (timed out under load), which is DISTINCT from "tmux up, no sessions" — the former
337
+ // means death is UNPROVEN so those rows read `unknown`, the latter is authoritative and reads `offline`.
338
+ export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; sockets: Set<string> }
339
+ async function liveSnapshot(): Promise<LiveSnap> {
340
+ const windows = new Map<string, PaneProbe>()
341
+ let out: string
342
+ try {
343
+ out = await tmux(['list-sessions', '-F', '#{session_name}\t#{pane_pid}'], TMUX_PROBE_TIMEOUT_MS)
344
+ } catch (e) {
345
+ // a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
346
+ // non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
347
+ return { probeFailed: probeTimedOut(e), windows, sockets: new Set() }
348
+ }
349
+ const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
350
+ for (const line of out.split('\n')) {
351
+ const tab = line.indexOf('\t'); if (tab < 0) { const name = line.trim(); if (name) windows.set(name, { procs }); continue }
352
+ const name = line.slice(0, tab).trim(); if (!name) continue
353
+ const pid = Number(line.slice(tab + 1).trim())
354
+ windows.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, procs })
355
+ }
356
+ // LISTENER probe for every windowed session, once, in parallel (tooth: a live listener, not a lingering
357
+ // socket file). A codex session has no rvSock → instant ENOENT → not in the set, and codex ignores it anyway.
358
+ const ids = [...windows.keys()]
359
+ const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
360
+ const sockets = new Set<string>()
361
+ ids.forEach((id, i) => { if (listening[i]) sockets.add(id) })
362
+ return { probeFailed: false, windows, sockets }
288
363
  }
289
364
 
290
365
  // @@@ paneTitles - every session pane's RAW tmux title, free from tmux. The worker launches one pane per
@@ -305,6 +380,19 @@ async function paneTitles(): Promise<Map<string, string>> {
305
380
  return m
306
381
  }
307
382
 
383
+ // @@@ sessionSignature - a CHEAP fingerprint of the two live board signals the session-store fs-watch can't
384
+ // see, because they are tmux-derived, not file writes: LIVENESS (which sessions exist — a crash/offline) and
385
+ // ACTIVITY (each pane's self-summary title). Two tmux calls, NO git and NO store walk, so [[board-stream]] can
386
+ // poll this to push a `board-changed` the instant a worker dies or updates its headline, instead of the
387
+ // dashboard waiting for its slow cold-path fallback. Sorted so it only moves on a real change.
388
+ export async function sessionSignature(): Promise<string> {
389
+ const [snap, titles] = await Promise.all([liveSnapshot(), paneTitles()])
390
+ // fold in probe-failure and the live-listener set so a socket dying (claude exit) OR the probe flipping to
391
+ // unknown pushes a board-changed immediately, not only on window churn.
392
+ return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
393
+ [...snap.sockets].sort().join(',') + '|' + [...titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
394
+ }
395
+
308
396
  // @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
309
397
  // NOT become) a session's headline activity. The board headline derives from the pane title ONLY for a
310
398
  // harness whose pane title is its own task self-summary (`paneTitleIsSelfSummary`, an adapter capability —
@@ -328,9 +416,17 @@ export function paneActivity(harness: Harness, paneTitle: string | null | undefi
328
416
  // splash. The leading glyph run (with the spaces/`·` between and after) is stripped — the dashboard draws its
329
417
  // own status dot, a frozen spinner frame is just noise — leaving only the summary text (null if it is empty).
330
418
  // ONE regex is the single source of the glyph rule: it gates (requires ≥1 glyph) and strips in one match.
419
+ // The glyph gate alone is not enough: Claude Code emits a glyph-led SPLASH of its own app name (`✳ Claude
420
+ // Code`) between pane birth and its first real task summary — it CLEARS the glyph gate yet is the app naming
421
+ // itself, not the task. GENERIC_SUMMARY rejects that stripped splash too, so the row keeps its launch-prompt
422
+ // placeholder instead of flashing "Claude Code" for a tick (the glyph-LESS `Claude Code` splash was already
423
+ // rejected by the gate; this catches its glyph-led twin).
424
+ const GENERIC_SUMMARY = /^claude code$/i
331
425
  export function selfSummary(paneTitle: string): string | null {
332
426
  const m = /^[\s·]*(?:[✳✶✻✽✢⠀-⣿][\s·]*)+(.*)$/u.exec(paneTitle)
333
- return m ? (m[1].trim() || null) : null
427
+ if (!m) return null
428
+ const text = m[1].trim()
429
+ return text && !GENERIC_SUMMARY.test(text) ? text : null
334
430
  }
335
431
 
336
432
  // @@@ launchedAt - when we last started a tmux window for an id (set in launch()). claude needs ~15-20s
@@ -339,23 +435,32 @@ export function selfSummary(paneTitle: string): string | null {
339
435
  // instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
340
436
  // memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
341
437
  const launchedAt = new Map<string, number>()
342
- const BOOT_GRACE_MS = 25000 // > waitForReady's 15s timeout, covering the observed ~15-20s agent boot window
438
+ const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
439
+ // window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
440
+ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this — the
441
+ // launcher daemon-not-ready race fails in ~8s; a real session runs far longer
343
442
 
344
443
  // @@@ 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 {
444
+ // lifecycle, from a prebuilt runtime snapshot (no per-call spawn — see liveSnapshot) + the adapter's own channel
445
+ // check. Order of honesty: if the PROBE ITSELF failed (tmux timed out under load) death is UNPROVEN `unknown`
446
+ // (render probe-failed, NEVER a false offline that empties the board and provokes a mass-restore). Else offline
447
+ // iff the tmux window is gone OR the adapter's online-signal is absent past the boot window. claude (via the
448
+ // reclaude wrapper) holds CLAUDE_BG_RENDEZVOUS_SOCK open the whole time it is alive, so a LIVE LISTENER on that
449
+ // socket (`snap.sockets`, connect-probed NOT the socket FILE, which a crash leaves behind) is the truth
450
+ // not the pane, whose foreground is the wrapper/shell while claude runs as its child. codex has no such socket,
451
+ // so its truth is the pane's DESCENDANT PROCESS TREE from the SAME snapshot: a live TUI keeps a codex/node
452
+ // process below the pane pid; a failed launch leaves the pane at a bare shell, even while the shared app-server
453
+ // sock lingers. A just-launched agent whose online-signal hasn't appeared yet reads the transient 'starting'
454
+ // for the grace window; only past it (still not online) is it genuinely 'offline'.
455
+ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
352
456
  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'.
457
+ if (snap.probeFailed) return 'unknown' // the probe failed we can't tell, and MUST NOT guess offline
458
+ // ask the ADAPTER ([[harness-adapter]]): claude = tmux up AND a live listener on its rendezvous socket; codex
459
+ // = tmux up AND a codex-ish process live among the pane pid's descendants (not the bare shell a failed launch
460
+ // dropped back to). The 'starting' grace stays here (a launcher concern): a just-launched agent whose
461
+ // online-signal hasn't appeared yet reads 'starting' for the boot window, only past it 'offline'.
357
462
  const h = harnessById(rec.harness || defaultHarness.id)
358
- if (h.liveness(rec, live.has(rec.session), runtimeRoot()) === 'online') return 'online'
463
+ if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
359
464
  const at = launchedAt.get(rec.session)
360
465
  return at && Date.now() - at < BOOT_GRACE_MS ? 'starting' : 'offline'
361
466
  }
@@ -363,14 +468,14 @@ function liveness(rec: SessRec, live: Set<string>): Liveness {
363
468
  // reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
364
469
  // surfaces ([[state]]), never a third source of truth. Lifecycle wins the label except where liveness must
365
470
  // 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
471
+ // liveness (offline/starting/unknown), else the active-only idle/working inference (the mark-active hook flips
472
+ // idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
368
473
  // terminal-mount and the relaunch panel on; this label is for badges and `spex ls`.
369
- function reconcile(rec: SessRec, live: Set<string>): DisplayStatus {
474
+ function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
370
475
  if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
371
476
  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'
477
+ const lv = liveness(rec, snap)
478
+ if (lv !== 'online') return lv // 'offline' | 'starting' | 'unknown'
374
479
  return rec.status === 'idle' ? 'idle' : 'working'
375
480
  }
376
481
 
@@ -383,12 +488,15 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
383
488
  return { path: rec.worktreePath, branch: rec.branch, rec }
384
489
  }
385
490
 
386
- function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
491
+ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
387
492
  const prompt = readPromptFile(rec.session) // the originating ask, captured at launch (store artifact; null for old sessions)
388
493
  // activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
389
494
  // dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
390
495
  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 }
496
+ const act = showActivity ? activity : null
497
+ const pp = prompt ? promptPreview(prompt) : null
498
+ const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
499
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: rec.harness, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
392
500
  }
393
501
 
394
502
  // @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
@@ -434,8 +542,8 @@ const lastKnownSession = new Map<string, Session>()
434
542
  export async function listSessions(): Promise<Session[]> {
435
543
  // ONE store enumeration + ONE tmux liveness snapshot + ONE pane-title snapshot for the whole list (all
436
544
  // 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(),
545
+ const [ids, snap, titles] = await Promise.all([
546
+ Promise.resolve(listSessionIds()), liveSnapshot(), paneTitles(),
439
547
  ])
440
548
  const rows = ids.map((id) => guardSession(id, () => {
441
549
  const rec = readRecord(id)
@@ -443,7 +551,7 @@ export async function listSessions(): Promise<Session[]> {
443
551
  // the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
444
552
  // is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
445
553
  const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), titles.get(id))
446
- const s = toSession(rec, reconcile(rec, live), liveness(rec, live), activity)
554
+ const s = toSession(rec, reconcile(rec, snap), liveness(rec, snap), activity)
447
555
  lastKnownSession.set(id, s)
448
556
  return s
449
557
  }, () => {
@@ -613,17 +721,26 @@ export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/se
613
721
  // backend-down poll must NOT be swallowed as a transient git/tmux hiccup: watch warns ONCE and keeps
614
722
  // streaming rather than emitting false `closed` events for every session.
615
723
  export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.name === 'BackendError'
724
+ // @@@ isBackendUnreachable - the TRANSIENT subset of isBackendDown: the fetch itself failed (nothing
725
+ // listening — ECONNREFUSED / "fetch failed"), which client.ts throws as a BackendError with NO HTTP
726
+ // `status`. An HTTP BackendError (the backend answered non-2xx) DOES carry a status and is a real error, not
727
+ // a momentary blip. The distinction matters to `spex wait`: a supervisor's backgrounded wait must survive
728
+ // the ~1s window where the supervisor reboots its hot-reloaded child behind the stable port, retrying until
729
+ // the backend answers again or the deadline hits — never dying on the in-flight fetch that a sibling merge's
730
+ // restart happens to interrupt. Read via a structural cast (no client.ts import — that would be a cycle).
731
+ export const isBackendUnreachable = (e: unknown): boolean =>
732
+ isBackendDown(e) && (e as { status?: number }).status === undefined
616
733
 
617
734
  const slugify = (s: string | null) => (s || 'session').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session'
618
735
 
619
736
  // @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
620
737
  // "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
621
738
  // 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.
739
+ // the FIRST `[[<id>]]` topic reference ([[mentions]]: `[[node]]` is a topic, `@` is now an actor/session).
740
+ // When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
624
741
  // 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_-]+)/
742
+ // (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
743
+ const MENTION = /\[\[(\.?[A-Za-z0-9_-]+)\]\]/
627
744
  const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
628
745
  function titleFromPrompt(prompt: string): string | null {
629
746
  const first = (prompt || '').trim().split('\n')[0].trim()
@@ -642,117 +759,52 @@ function titleFromPrompt(prompt: string): string | null {
642
759
  // `reclaude` wrapper, a grandchild), so the pane command is the wrapper/shell — reconcile reads claude's
643
760
  // rendezvous socket instead (present while claude is alive, gone once it exits). The file lives OUTSIDE the
644
761
  // 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 {
762
+ // the launch command for THIS session ([[launcher-select]] resume-launcher-pin): the RESOLVED base command
763
+ // PINNED on the record at creation wins — so a (re)launch replays the EXACT launcher that made the conversation
764
+ // (and its config-dir env), never re-resolving against a since-changed default that would send `--resume` to the
765
+ // wrong config dir and lose the transcript. Fall back to the named-launcher resolution (an old record with a
766
+ // launcher name but no pinned cmd; fail-loud on a since-removed launcher), then undefined (truly old record →
767
+ // the harness adapter's ambient resolution, best-effort).
768
+ export function launcherCmd(rec: SessRec): string | undefined {
769
+ if (rec.launchCmd) return rec.launchCmd
770
+ return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
771
+ }
772
+ function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
646
773
  const file = join(storeDir(id), 'launch.sh')
647
774
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
648
775
  // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
649
776
  // 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`)
777
+ // `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
778
+ // ambient default so resume reuses the same auth, else undefined → the unnamed global resolution.
779
+ const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
780
+ // Bounded relaunch on a FAST exit: the agent launcher (e.g. the reclaude daemon) can lose a startup
781
+ // race ("daemon did not become ready") and exit within seconds before the rendezvous socket ever
782
+ // appears — a fast exit is the race, not real work, so retry a few times. Once the agent has run past
783
+ // LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal session end
784
+ // and is NEVER retried — the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this retry
785
+ // window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This only
786
+ // closes the startup race — it adds no fallback and never masks a genuinely dead agent (3 attempts, then give up).
787
+ writeFileSync(file, [
788
+ `for __spex_try in 1 2 3; do`,
789
+ ` __spex_t0=$SECONDS`,
790
+ ` ${invocation}`,
791
+ ` __spex_rc=$?`,
792
+ ` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
793
+ ` printf '[spex launch] attempt %s exited in %ss (rc=%s) — likely a launcher daemon race; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
794
+ ` sleep 2`,
795
+ `done`,
796
+ `exit $__spex_rc`,
797
+ ``,
798
+ ].join('\n'))
651
799
  return file
652
800
  }
653
- async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS): Promise<void> {
801
+ async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
654
802
  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)}`])
803
+ await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
656
804
  await tmux(['send-keys', '-t', id, 'Enter'])
657
805
  launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
658
806
  }
659
807
 
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
808
 
757
809
  // @@@ concurrency cap + queue - keep at most maxActive() agents AUTONOMOUSLY PROGRESSING at once. A slot is
758
810
  // COMPUTE pressure, so only an agent actually consuming it holds one: genuinely live (tmux window + rendezvous
@@ -764,11 +816,11 @@ function deleteNodePrompt(nodeId: string, relPath: string | null, rest: string):
764
816
  // directly (the socket truth reconcile uses), so an authored `parked` whose claude has since died does NOT
765
817
  // pin a slot. The cap throttles concurrent COMPUTE; everything waiting-on-you waits cheap as a live pane.
766
818
  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 {
819
+ function isOccupying(s: Session, snap: LiveSnap): boolean {
768
820
  if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
769
821
  const rec = readRecord(s.id)
770
822
  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)
823
+ return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online' // and only while the agent is genuinely live (its adapter's channel)
772
824
  }
773
825
  // sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
774
826
  // `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
@@ -790,7 +842,7 @@ async function startQueued(id: string): Promise<boolean> {
790
842
  const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
791
843
  try {
792
844
  const sq = `'${launchPrompt.replace(/'/g, `'\\''`)}'`
793
- await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h)
845
+ await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec))
794
846
  } catch {
795
847
  launching.delete(id)
796
848
  return false // launch failed → stays `queued`, retried on the next drain tick
@@ -812,10 +864,15 @@ export async function drainQueue(): Promise<void> {
812
864
  if (draining) return
813
865
  draining = true
814
866
  try {
815
- const cap = maxActive() // read once per drain pass (spexcode.json → env → 6); won't shift mid-burst
867
+ const cap = maxActive() // read once per drain pass (spexcode.json → env → default); won't shift mid-burst
816
868
  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)
869
+ const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()])
870
+ // if the liveness probe FAILED (tmux timing out the overload condition), occupancy is UNKNOWABLE: every
871
+ // session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
872
+ // MORE compute onto an already-thrashing box. Under load, do the safe thing — launch nothing this pass and
873
+ // let the next tick re-drain once the probe recovers ([[state]] board honesty applied to the cap).
874
+ if (snap.probeFailed) break
875
+ const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0)
819
876
  if (occupied >= cap) break
820
877
  const next = sessions.find((s) => s.status === 'queued' && !launching.has(s.id))
821
878
  if (!next) break
@@ -840,6 +897,32 @@ export function superviseQueue(intervalMs = 3000): void {
840
897
  void tick()
841
898
  }
842
899
 
900
+ // @@@ assertProjectMatch - the launch is PROJECT-BOUND, but routing is by URL. `spex new`'s intent is "create a
901
+ // session for the project my cwd is in", yet `apiBase()` is SPEXCODE_API_URL||PORT||8787 — a pure URL carrying
902
+ // no project identity. The backend it answers builds the worktree under ITS OWN mainRoot, so a stale inherited
903
+ // SPEXCODE_API_URL (e.g. pointing at another repo's backend) silently lands the session in the WRONG repo. Read/
904
+ // control verbs deliberately point anywhere (viewer-points-anywhere, see remote-client); only the mutating
905
+ // launch is bound to the caller's project. So before launching, compare the caller's repo root to the backend's
906
+ // served root and FAIL LOUD on a provable, same-host mismatch — never a silent misroute. The guard fires only on
907
+ // a positive mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local
908
+ // path (a genuinely remote backend) all fall through to allow, so legit remote dispatch is untouched.
909
+ async function assertProjectMatch(): Promise<void> {
910
+ let localMain: string
911
+ try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
912
+ let served: string | null = null
913
+ try {
914
+ const r = await fetch(`${apiBase()}/api/layout`)
915
+ if (r.ok) served = (await r.json() as { main?: string }).main ?? null
916
+ } catch { return } // backend unreachable → the POST surfaces it (fail-loud there)
917
+ if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
918
+ let backendMain: string
919
+ try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
920
+ if (backendMain !== localMain)
921
+ throw new Error(
922
+ `spex new: refusing — cwd is in ${localMain} but the backend at ${apiBase()} serves ${backendMain}.\n` +
923
+ `Start this project's backend: cd ${localMain} && spex serve (or point SPEXCODE_API_URL at it)`)
924
+ }
925
+
843
926
  // @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
844
927
  // BACKEND's process, not the caller's. The backend owns the launch env (notably SPEXCODE_CLAUDE_CMD, which
845
928
  // reclaude strips from agent envs) AND the concurrency cap. An agent that runs `spex new` (e.g. a supervisor)
@@ -847,17 +930,23 @@ export function superviseQueue(intervalMs = 3000): void {
847
930
  // the CLI POSTs to the running backend whenever one answers, making the backend the single owner of session
848
931
  // launching. Only when NO backend is reachable do we fall back to launching in this process (with a stderr
849
932
  // 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> {
933
+ export async function createSession(node: string | null, prompt: string, harness: string = defaultHarness.id, launcher?: string): Promise<Session> {
934
+ await assertProjectMatch()
935
+ // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
936
+ // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
937
+ // backend, whose process env carries no acting session id. An agent that runs `spex new` stamps its own id;
938
+ // a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
939
+ const parent = ownSessionId()
851
940
  let res: Response
852
941
  try {
853
942
  res = await fetch(`${apiBase()}/api/sessions`, {
854
943
  method: 'POST',
855
944
  headers: { 'content-type': 'application/json' },
856
- body: JSON.stringify({ node, prompt, harness }),
945
+ body: JSON.stringify({ node, prompt, harness, parent, launcher }),
857
946
  })
858
947
  } catch {
859
948
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
860
- return newSession(node, prompt, harness)
949
+ return newSession(node, prompt, harness, parent, launcher)
861
950
  }
862
951
  if (!res.ok) throw new Error(`backend rejected session (${res.status}): ${await res.text().catch(() => '')}`)
863
952
  return await res.json() as Session
@@ -866,19 +955,25 @@ export async function createSession(node: string | null, prompt: string, harness
866
955
  // @@@ newSession - durable worktree (branch node/<slug> off main) + .session label. The agent does NOT
867
956
  // launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
868
957
  // 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> {
958
+ // `spex session new`. Creating or deleting a spec node is NOT a server op it is prompt-driven work the
959
+ // launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
960
+ // only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
961
+ // programmatic surface, every other surface is prompt only).
962
+ export async function newSession(node: string | null, prompt: string, harness: string = defaultHarness.id, parent: string | null = null, launcher?: string): Promise<Session> {
872
963
  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)}`
964
+ // a named launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below, re-resolved at each
965
+ // launch so resume reuses the same auth) AND the harness — so picking one SUBSUMES the harness axis. Explicit
966
+ // --launcher wins, else the configured defaultLauncher; an unknown name throws fail-loud (never a silent
967
+ // wrong-auth launch). No launcher → the harness arg (the zero-config path) with a null launcher field → the
968
+ // unnamed global command resolution at launch.
969
+ const lname = launcher ?? readConfig(mainRoot()).sessions?.defaultLauncher ?? null
970
+ const chosen = lname ? resolveLauncher(lname) : null
971
+ const h = harnessById(chosen ? chosen.harness : harness) // throws on an unknown id — fail loud, never silently launch the wrong harness
972
+ // node identity + label: explicit --node wins, else the prompt's first `[[id]]` topic ref; a prompt with
973
+ // none is node-agnostic and labeled by its first few words.
974
+ const ref = node || mentionedNode(prompt)
975
+ const title = ref ? null : titleFromPrompt(prompt)
976
+ const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
882
977
  const branch = `node/${slug}`
883
978
  const path = join(mainRoot(), '.worktrees', slug)
884
979
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
@@ -889,8 +984,15 @@ export async function newSession(node: string | null, prompt: string, harness: s
889
984
  // the worktree, is the board's enumeration source now).
890
985
  const rec: SessRec = {
891
986
  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,
987
+ // parent = the SPAWNING session's id, captured ONCE here ([[session-nesting]]): a durable pointer, never
988
+ // mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
989
+ node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
990
+ status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
991
+ harness: h.id, harnessSessionId: null, launcher: lname,
992
+ // PIN the resolved base launcher command NOW ([[launcher-select]] resume-launcher-pin) so every future
993
+ // (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
994
+ // re-resolving against a default that may have flipped (a backend restarted under a different launcher).
995
+ launchCmd: h.baseCmd(chosen?.cmd),
894
996
  }
895
997
  writeRecord(rec)
896
998
  writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
@@ -900,18 +1002,9 @@ export async function newSession(node: string | null, prompt: string, harness: s
900
1002
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
901
1003
  // own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
902
1004
  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
1005
  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.
1006
+ if (ref) {
1007
+ // @@@ spec pointer - the ref (explicit --node, else the prompt's first [[id]] ref) named an EXISTING node.
915
1008
  // Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
916
1009
  // it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
917
1010
  // is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
@@ -936,50 +1029,64 @@ export async function newSession(node: string | null, prompt: string, harness: s
936
1029
  // BOUNDED + fail-loud preserved: a
937
1030
  // genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
938
1031
  // deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
939
- const SOCKET_READY_TIMEOUT_MS = 15000
1032
+ const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
1033
+ // waitForReady (slot-hold + reopen) waits through a daemon-race retry
1034
+ // instead of returning before a recovering socket
940
1035
  const SOCKET_POLL_MS = 200
941
1036
  async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<boolean> {
942
1037
  const deadline = Date.now() + timeoutMs
943
1038
  for (;;) {
944
1039
  const rec = readRecord(id)
945
- if (rec && harness.liveness(rec, await alive(id), runtimeRoot()) === 'online') return true
1040
+ const snap = await liveSnapshot() // window + pane probe + live-listener set in one snapshot — all the adapter needs
1041
+ if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online') return true
946
1042
  if (Date.now() >= deadline) return false
947
1043
  await new Promise((r) => setTimeout(r, SOCKET_POLL_MS))
948
1044
  }
949
1045
  }
950
1046
 
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> {
1047
+ // @@@ reopen - bring the agent back up and settle its RESTING lifecycle. THREE rules:
1048
+ // RESUME GUARD ([[state]]): a relaunch KILLS the running agent (`kill-session` + fresh window), so it is a
1049
+ // data-loss operation the moment the agent is actually ALIVE the incident's kill-shot was restore-on-alive
1050
+ // (the board LIED offline, the human relaunched, live workers died mid-work). So reopen re-derives the
1051
+ // agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session reopen`,
1052
+ // `guard` default true), REFUSES LOUD rather than relaunch a live agent you steer a live agent by
1053
+ // MESSAGING it, not by restoring it. Death must be PROVEN: an `unknown` probe (tmux timed out under load —
1054
+ // the exact condition that started the incident) also refuses, since a live worker can't be ruled out. A
1055
+ // `force` escape exists for a genuinely-wedged-but-alive process. The merge dispatch passes `guard:false`:
1056
+ // it only needs a LIVE agent to send the merge prompt to, so an already-online agent is a satisfied no-op
1057
+ // (never a refusal), and only a CONFIRMED-offline one is relaunched.
1058
+ // • liveness/relaunch: relaunch only when the agent is CONFIRMED `offline` (or `force`) never on `online`
1059
+ // (alive), `starting` (booting), or `unknown` (unproven). We drop any stale pane and launch a fresh window
1060
+ // through the adapter's resumeArg — claude `--resume <id>` (the SAME conversation), codex `resume
1061
+ // <thread-id>` once captured, else a fresh TUI — then WAIT (waitForReady) so a caller that dispatches
1062
+ // immediately after (mergeSession's merge) addresses a LIVE agent, not a racing boot.
1063
+ // • lifecycle: the SAME active-only guard markIdle uses — a resumed agent that was WORKING (`active`) is now
1064
+ // just sitting at its prompt → `idle`; EVERY deliberate declaration survives untouched (`awaiting` + its
1065
+ // proposal, `asking`, `parked`, `error`, `queued`). reopen does NOT touch the `proposal` — resuming a
1066
+ // session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
1067
+ // a refusal leaves the record wholly untouched.
1068
+ // Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
1069
+ export async function reopen(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
1070
+ const { force = false, guard = true } = opts
963
1071
  const wt = await findWorktree(id)
964
- if (!wt) return false
1072
+ if (!wt) return { ok: false, error: `no such session ${id}` }
965
1073
  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)
1074
+ const lv = liveness(wt.rec, await liveSnapshot()) // FRESH, honest liveness (listener-verified) — the guard must not trust a stale board reading
1075
+ if (guard && !force && lv === 'online')
1076
+ return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` }
1077
+ if (guard && !force && lv === 'unknown')
1078
+ return { ok: false, refused: true, error: `session ${id}: the liveness probe failed (the box is likely overloaded) — refusing to relaunch since a live worker can't be ruled out. Retry in a moment, or use force to override.` }
1079
+ // proceeding: settle the RESTING lifecycle (a resumed working agent is now idle), then relaunch iff the agent
1080
+ // is CONFIRMED offline (or force — the wedged-but-alive escape). `starting`/`unknown` fall through to a no-op.
1081
+ writeRecord({ ...wt.rec, status: wt.rec.status === 'active' ? 'idle' : wt.rec.status })
1082
+ if (force || lv === 'offline') {
1083
+ await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
1084
+ await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec)) // resume under the SAME persisted launcher ([[launcher-select]])
970
1085
  await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
971
1086
  }
972
- return true
1087
+ return { ok: true }
973
1088
  }
974
1089
 
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
1090
  // @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
984
1091
  // authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
985
1092
  // SOMETHING changed, not the transition, so they force a write, never infer. The session it writes is resolved
@@ -1064,13 +1171,15 @@ function porcelainPath(line: string): string {
1064
1171
  // @@@ MANAGER COCKPIT - the review payload (the cockpit's first verb; see the manager-cockpit spec node).
1065
1172
  // One server-side bundle that lets a manager (human or agent) decide whether to merge a session WITHOUT
1066
1173
  // 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.
1174
+ // whether uncommitted non-runtime work remains, the merge/lint gates, and the agent's standing proposal.
1175
+ // ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); lint reflects the CLI
1176
+ // package's OWN location (where this runs) — the spec-cli that's actually live. There is deliberately NO
1177
+ // build/typecheck/test gate: whether a change is SOUND is proven by the node's yatsu (measured through the
1178
+ // real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
1179
+ // the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
1180
+ // has that id.
1071
1181
  export type ReviewGates = {
1072
1182
  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
1183
  lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
1075
1184
  }
1076
1185
  export type ReviewPayload = {
@@ -1082,37 +1191,59 @@ export type ReviewPayload = {
1082
1191
  proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
1083
1192
  }
1084
1193
 
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
- })
1194
+ // @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
1195
+ // (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
1196
+ // it on every reviewPayload i.e. on every [[review-proof]] Proof-tab open, and once per sessionis
1197
+ // wasteful, so memoize it on a whole-repo fingerprint: `rev-parse HEAD` + `status --porcelain` + the mtimes of
1198
+ // the changed paths (covers committed state, the dirty SET, and dirty-file CONTENT). An identical fingerprint
1199
+ // reuses the last (in-flight) result — a re-open or a second session's proof is instant — while any commit or
1200
+ // working-tree edit moves the fingerprint and recomputes. A rejected run is not cached.
1201
+ let gateCache: { fp: string; p: Promise<ReviewGates['lint']> } | null = null
1202
+ async function lintGate(): Promise<ReviewGates['lint']> {
1203
+ const root = repoRoot()
1204
+ const [head, status] = await Promise.all([
1205
+ gitA(['-C', root, 'rev-parse', 'HEAD']),
1206
+ gitA(['-C', root, 'status', '--porcelain', '--untracked-files=all']),
1207
+ ])
1208
+ // `status --porcelain` gives the SET of changed paths + status letters but is CONTENT-BLIND: re-editing an
1209
+ // already-listed (dirty or untracked) file leaves the string byte-identical, so HEAD+status alone would
1210
+ // freeze the gate after a file first goes dirty. `--untracked-files=all` stops an untracked dir from
1211
+ // collapsing to one line (which hides a newly-added file); then fold each listed path's mtime in, so a
1212
+ // content edit to a dirty file also moves the fingerprint. HEAD covers committed state, this covers the
1213
+ // working tree. (Residual, accepted: the fingerprint is snapshot just before the compute, so a change
1214
+ // landing mid-compute is labelled with the pre-change fp — rare, and the gate is advisory, re-verified at merge.)
1215
+ const mtimes = status.split('\n').filter(Boolean).map(porcelainPath)
1216
+ .map((p) => { try { return statSync(join(root, p)).mtimeMs } catch { return 0 } }).join(',')
1217
+ const fp = head.trim() + '\n' + status + '\n' + mtimes
1218
+ if (gateCache?.fp === fp) return gateCache.p
1219
+ const p = (async () => {
1220
+ const { specLint } = await import('./lint.js')
1221
+ const findings = await specLint()
1222
+ return {
1223
+ errorCount: findings.filter((f) => f.level === 'error').length,
1224
+ warningCount: findings.filter((f) => f.level === 'warn').length,
1225
+ }
1226
+ })()
1227
+ p.catch(() => { if (gateCache?.p === p) gateCache = null }) // don't pin a failed run
1228
+ gateCache = { fp, p }
1229
+ return p
1098
1230
  }
1099
1231
 
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).
1232
+ // @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
1233
+ // (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
1234
+ // in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
1235
+ // so an unchanged tree doesn't re-run the lint on each review / Proof-tab open, while any commit or edit
1236
+ // invalidates and recomputes.
1104
1237
  export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1105
1238
  const wt = await findWorktree(id)
1106
1239
  if (!wt) return null
1107
- const { specLint } = await import('./lint.js')
1108
1240
  const base = mainBranch()
1109
- const [aheadOut, statusOut, diff, conflictsWithMain, typecheck, findings] = await Promise.all([
1241
+ const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
1110
1242
  gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
1111
1243
  gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
1112
1244
  mergeBaseDiff(wt.path, base),
1113
1245
  mergeConflicts(wt.path, base),
1114
- typecheckPkg(),
1115
- specLint(),
1246
+ lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
1116
1247
  ])
1117
1248
  // the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
1118
1249
  // path is genuine work — this is just the total uncommitted count.
@@ -1121,13 +1252,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1121
1252
  id, node: wt.rec.node, branch: wt.branch,
1122
1253
  ahead: Number(aheadOut.trim()) || 0,
1123
1254
  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
- },
1255
+ gates: { conflictsWithMain, lint },
1131
1256
  proposal: { kind: wt.rec.proposal, note: wt.rec.note },
1132
1257
  }
1133
1258
  }
@@ -1150,8 +1275,9 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
1150
1275
 
1151
1276
  // @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
1152
1277
  // 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 —
1278
+ // main's tree). It reopens the session (clears the proposal, `--resume`s via reopen if tmux died —
1154
1279
  // which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
1280
+ // — that delivered prompt flips the lifecycle to active regardless of reopen's resting state
1155
1281
  // through sendKeys. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
1156
1282
  // the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
1157
1283
  // CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
@@ -1160,7 +1286,10 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
1160
1286
  const wt = await findWorktree(id)
1161
1287
  if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
1162
1288
  const branch = wt.branch, main = mainRoot()
1163
- if (!(await reopen(id))) return { dispatched: false, reason: 'could not reopen session' }
1289
+ // ensure-live, NOT the guarded human relaunch: an already-online agent is reused (the merge prompt just needs
1290
+ // a live socket), and only a confirmed-offline one is relaunched — so merge never refuses on a live agent.
1291
+ const re = await reopen(id, { guard: false })
1292
+ if (!re.ok) return { dispatched: false, reason: re.error || 'could not reopen session' }
1164
1293
  const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
1165
1294
  const reason = subject.replace(/^spec:\s+/, '') || branch
1166
1295
  const r = await sendKeys(id, mergePrompt(main, branch, reason))
@@ -1237,10 +1366,10 @@ export async function captureSessionResult(id: string): Promise<CaptureResult> {
1237
1366
  // @@@ presentation + selection - shared by `spex ls` (pretty), `spex watch` (events) and the API.
1238
1367
  export const STATUS_GLYPH: Record<DisplayStatus, string> = {
1239
1368
  working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
1240
- 'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc',
1369
+ 'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
1241
1370
  }
1242
1371
  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',
1372
+ working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90', unknown: '93',
1244
1373
  }
1245
1374
 
1246
1375
  // @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
@@ -1249,7 +1378,13 @@ const ANSI: Record<DisplayStatus, string> = {
1249
1378
  // resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
1250
1379
  // review/merge/send/close act on".
1251
1380
  export function matchesSelector(s: Session, q: string): boolean {
1252
- return s.id === q || s.id.startsWith(q) || s.node === q || s.branch === q
1381
+ // a selector may be a comma-separated list (the same convention as `--status a,b`): it matches iff ANY part
1382
+ // names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
1383
+ // is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
1384
+ // comma, so without the split `a,b` would be one literal selector that matches no session and streams in
1385
+ // silence forever.
1386
+ return q.split(',').map((p) => p.trim()).filter(Boolean)
1387
+ .some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
1253
1388
  }
1254
1389
 
1255
1390
  // no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
@@ -1311,9 +1446,9 @@ export function formatTable(sessions: Session[], color = true): string {
1311
1446
 
1312
1447
  const WATCH_ACTIONABLE = new Set<DisplayStatus>(['review', 'done', 'close-pending', 'offline', 'error', 'asking'])
1313
1448
  const NEXT: Record<string, string> = {
1314
- review: 'merge | reopen(back-to-working) | close',
1315
- done: 'merge | reopen | close',
1316
- 'close-pending': 'close | reopen',
1449
+ review: 'merge | close',
1450
+ done: 'merge | close',
1451
+ 'close-pending': 'close',
1317
1452
  offline: 'reopen (relaunch & resume)',
1318
1453
  error: 'reopen (relaunch & retry) | capture | close',
1319
1454
  asking: 'send "<msg>" | capture',
@@ -1354,6 +1489,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1354
1489
  const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
1355
1490
  const isActionable = (st: DisplayStatus) => WATCH_ACTIONABLE.has(st) || (includeIdle && st === 'idle')
1356
1491
  let warnedDown = false
1492
+ let downMsg: string | null = null // set while the backend is unreachable, cleared on a good poll; the deadline reports it
1357
1493
  for (;;) {
1358
1494
  try {
1359
1495
  // EXISTENCE is the selector-matched board across ALL statuses — listSessions now lists every worktree
@@ -1362,7 +1498,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1362
1498
  // emit, never whether a session is present — using it for presence would read a status change out of the
1363
1499
  // filtered set as a (false) removal.
1364
1500
  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
1501
+ warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
1366
1502
  const ids = new Set(all.map((s) => s.id))
1367
1503
  const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
1368
1504
  for (const s of all) {
@@ -1389,23 +1525,33 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1389
1525
  if (!all.length) return { gone: true }
1390
1526
  }
1391
1527
  } 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…`) }
1528
+ // a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
1529
+ // we skip the tick (prev is untouched → no phantom removals). Two shapes of BackendError diverge here:
1530
+ // REACHABLE but erroring (an HTTP status) the backend answered and is broken: a real terminal
1531
+ // condition, so a bounded `wait` fails loud IMMEDIATELY rather than spinning out its whole timeout.
1532
+ // • UNREACHABLE (no status ECONNREFUSED / fetch failed) nothing is listening, e.g. the supervisor
1533
+ // is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
1534
+ // record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
1535
+ // backgrounded `spex wait` survives the ~1s restart instead of dying on the interrupted fetch.
1536
+ if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message }
1537
+ if (isBackendDown(e)) {
1538
+ downMsg = (e as Error).message
1539
+ if (!warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${downMsg}; retrying every ${intervalMs / 1000}s…`) }
1540
+ }
1397
1541
  }
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 }
1542
+ // the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep: this
1543
+ // guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE — nor spin forever on a
1544
+ // backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
1545
+ // not a false "no actionable status" timeout, so the manager sees the honest cause.
1546
+ if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg } : { timedOut: true }
1401
1547
  await sleep(intervalMs)
1402
1548
  }
1403
1549
  }
1404
1550
 
1405
1551
  // @@@ 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
1552
+ // ([[harness-adapter]]) — claude the rendezvous control socket (optimistic-after-liveness: the reply line flushes
1553
+ // to a live socket), codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
1554
+ // fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
1409
1555
  // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
1410
1556
  // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
1411
1557
  // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
@@ -1441,8 +1587,9 @@ const TMUX_KEY: Record<string, string> = {
1441
1587
  // literal text "S-Enter" etc. (and shift is a no-op there anyway), so a stray S- is dropped. Shift+Tab is
1442
1588
  // the named exception: tmux spells it `BTab` (back-tab → ESC[Z, what Claude Code's mode-cycle reads).
1443
1589
  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
1590
+ // resolve ONE frontend token to the `tmux send-keys` args for it, or null if it isn't a known base after its
1591
+ // prefixes (defends the send-keys arg). Pure — the batch loop below sequences the actual sends.
1592
+ function rawKeyArgs(id: string, key: string): string[] | null {
1446
1593
  // peel the optional C-/M-/S- modifier prefixes (each at most once, in any order) off the front; the
1447
1594
  // remainder is the BASE key. The frontend only ever sends {C-,M-,S-} prefixes + a named key or one char.
1448
1595
  let rest = key, prefix = ''
@@ -1457,13 +1604,29 @@ export async function rawKey(id: string, key: string): Promise<boolean> {
1457
1604
  if (prefix.includes('S-') && named === 'Tab') token = noShift + 'BTab' // Shift+Tab → back-tab
1458
1605
  else if (prefix.includes('S-') && !SHIFTABLE.has(named)) token = noShift + named // tmux can't carry S- here
1459
1606
  else token = prefix + named
1460
- await tmux(['send-keys', '-t', id, token]); return true
1607
+ return ['send-keys', '-t', id, token]
1461
1608
  }
1462
1609
  if ([...rest].length === 1) {
1463
1610
  // a single printable char: bare → literal (`-l`, so tmux never reinterprets it as a key name);
1464
1611
  // 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
1612
+ if (prefix) return ['send-keys', '-t', id, prefix + rest]
1613
+ return ['send-keys', '-t', id, '-l', '--', rest]
1614
+ }
1615
+ return null
1616
+ }
1617
+ // One call carries a BATCH of tokens (or one) — the client coalesces fast typing into an ordered array. Order
1618
+ // is the whole point ([[nav-mode-key-ordering]]): the keys are sent by ONE awaited `send-keys` each, IN ARRAY
1619
+ // ORDER, so they reach the pane in exactly the order they were struck. Concurrent per-key POSTs used to race
1620
+ // (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
1621
+ // An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
1622
+ export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
1623
+ const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
1624
+ if (list.length === 0 || !(await alive(id))) return false
1625
+ let sent = false
1626
+ for (const k of list) {
1627
+ const args = rawKeyArgs(id, k)
1628
+ if (!args) continue
1629
+ await tmux(args); sent = true
1467
1630
  }
1468
- return false
1631
+ return sent
1469
1632
  }