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,9 +1,9 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { homedir, tmpdir } from 'node:os'
4
4
  import { createHash, randomBytes } from 'node:crypto'
5
5
  import { createConnection, type Socket } from 'node:net'
6
- import { execFile } from 'node:child_process'
6
+ import { execFile, execFileSync } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
@@ -24,6 +24,11 @@ import { tsxBin } from './tsx-bin.js'
24
24
 
25
25
  export type HarnessId = 'claude' | 'codex'
26
26
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
27
+ // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
28
+ // the pane's root pid (tmux `#{pane_pid}`) plus one whole-box pid→(ppid, comm) table (a single `ps` spawn).
29
+ // The adapter that cares (codex) walks the pane pid's descendants in that table; claude ignores it.
30
+ export type ProcTable = Map<number, { ppid: number; comm: string }>
31
+ export type PaneProbe = { panePid?: number; procs?: ProcTable }
27
32
 
28
33
  export interface Harness {
29
34
  readonly id: HarnessId
@@ -44,9 +49,19 @@ export interface Harness {
44
49
  readonly paneTitleIsSelfSummary: boolean
45
50
 
46
51
  // --- launch / sessionId ---
47
- // the base agent command (env-overridable for tests). Claude: `claude …`; Codex starts a project-scoped
48
- // app-server and launches the visible TUI with `--remote` pointed at it.
49
- launchCmd(id: string, runtimeDir?: string): string
52
+ // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
53
+ // visible TUI with `--remote` pointed at it. `cmd`, when given, is the SESSION's persisted launcher command
54
+ // ([[launcher-select]]) and OVERRIDES the ambient env→config→default resolution — so a session created under a
55
+ // named launcher keeps that exact command (and auth) on resume, never silently reverting to the global
56
+ // default. Omitted → the unnamed default resolution (env-overridable, for tests + old records).
57
+ launchCmd(id: string, runtimeDir?: string, cmd?: string): string
58
+ // the RESOLVED base launcher command alone — the wrapper/binary that carries the agent's config-dir env
59
+ // (claude `CLAUDE_CONFIG_DIR`, codex `CODEX_HOME`), WITHOUT the per-launch script built around it. `cmd`,
60
+ // when given, wins; else the ambient env→config→default resolution. The launch owner PINS this on the record
61
+ // at creation so a resume replays the EXACT launcher that created the conversation — never re-resolving
62
+ // against a since-changed default, which would point `--resume` at the wrong config dir and lose the
63
+ // transcript ([[launcher-select]], the resume-launcher-pin). launchCmd builds its invocation ON TOP of this.
64
+ baseCmd(cmd?: string): string
50
65
  // the flag that pins the session id at launch. Claude lets the caller choose (`--session-id <id>`); Codex
51
66
  // assigns its own, so there is nothing to pass (the id is captured/resumed afterwards).
52
67
  sessionIdArg(id: string): string
@@ -56,16 +71,37 @@ export interface Harness {
56
71
  // --- materialize: shim + contract + trust ([[harness-delivery]]) ---
57
72
  // the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
58
73
  shimFile(proj: string): string
74
+ // a LINKED WORKTREE's extra shim copy — the worktree-side `.codex` hook file that ANCHORS codex's project
75
+ // config layer, or null when the harness needs none. codex-rs only builds a project config layer (and thus
76
+ // only DISCOVERS a worktree thread's hooks) for a dir in [cwd..project_root] that contains a `.codex/`
77
+ // directory; it then REWRITES that layer's hooks-config folder to the ROOT checkout (root_checkout_hooks_-
78
+ // folder_for_dir), so the shim CONTENT is still read from `shimFile` at the main checkout. But with the codex
79
+ // shim living ONLY at the main checkout, a linked worktree has NO `.codex/` at all → codex anchors no layer →
80
+ // the rewritten root hooks are never visited → ZERO hooks fire (bypass_hook_trust can't help: it only rescues
81
+ // an untrusted HANDLER inside an already-discovered layer, it never creates one). So codex ALSO writes its
82
+ // shim into the worktree's own `.codex/hooks.json` purely to anchor the layer (the rewrite ignores its
83
+ // content, reading the root's — and a codex that DIDN'T rewrite would read this identical shim, so it is
84
+ // correct either way). Claude: null — its shim already lives IN the worktree (`.claude/settings.json`) and
85
+ // self-anchors; it has no root-checkout rewrite. Non-worktree (proj == main checkout): null — `shimFile`
86
+ // already wrote `.codex/hooks.json` there.
87
+ worktreeHookAnchor(proj: string): string | null
59
88
  // the contract file(s) the `surface: system` block is folded into. Claude: ./CLAUDE.md; Codex: ONLY ./AGENTS.md.
60
89
  contractFiles(proj: string): string[]
61
90
  // the dir this harness auto-discovers skills from, or null if it has no skill primitive — the ONLY place skill-surface divergence lives.
62
91
  skillDir(proj: string): string | null
92
+ // the dir this harness auto-discovers sub-agent definitions from, or null if it has no agent primitive — the
93
+ // ONLY place agent-surface divergence lives (the skillDir analog). Claude reads .claude/agents/<name>.md;
94
+ // Codex has no file-discovered agent-definition primitive, so it returns null and materialize skips it.
95
+ agentDir(proj: string): string | null
63
96
  // the shim payload: the settings/hooks JSON binding every event → the dispatcher (harness id baked in), and
64
97
  // the per-event command string (shared with the trust writer so they hash identically).
65
98
  shim(dispatch: string, spex: string): { json: string; cmd: (e: string) => string }
66
- // make a user-self-launched agent run the hooks with zero prompts. Codex writes a deterministic trusted_hash
67
- // into the GLOBAL ~/.codex/config.toml (codex's security model: trust is global-only); Claude is a no-op
68
- // (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim emitted.
99
+ // make a dispatched/self-launched agent run the hooks with zero prompts. Codex writes PROJECT trust — and, on
100
+ // a binary without `--dangerously-bypass-hook-trust`, per-hook trusted_hash blocks into the GLOBAL
101
+ // ~/.codex/config.toml (codex's security model: trust is global-only). PROJECT trust is UNCONDITIONAL: it
102
+ // ENABLES the project config layer so codex discovers our hooks at all, a tier bypass_hook_trust does NOT
103
+ // cover. Claude is a no-op (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim
104
+ // emitted.
69
105
  writeTrust(proj: string, cmdFor: (e: string) => string): void
70
106
 
71
107
  // --- the `/` menu ---
@@ -73,21 +109,38 @@ export interface Harness {
73
109
  slashCommands(): SlashCommand[]
74
110
 
75
111
  // --- runtime: liveness + prompt delivery ([[harness-delivery]]) ---
76
- // is this session's agent process up? The caller passes in the tmux-window presence it already computed
77
- // (one tmux snapshot for the whole list — see sessions.ts liveTmux), and the adapter adds ONLY its own
78
- // channel check. claude: online iff the tmux window is up AND its reclaude rendezvous socket exists (the
79
- // socket is the truth claude is alive the pane command is the wrapper/shell while claude runs as a child).
80
- // codex: online iff the tmux window is up AND the project-scoped app-server socket exists (one socket per
81
- // PROJECT, shared by every worktree's thread); the per-session window presence is the session signal, the
82
- // socket is a project control plane, not session identity.
83
- liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string): 'online' | 'offline'
112
+ // is this session's agent process up? The caller passes the runtime facts it already computed in ONE
113
+ // snapshot (see sessions.ts liveSnapshot): the window's presence, a PaneProbe — the pane's root pid plus one
114
+ // whole-box process table AND `socketLive`, whether a CONNECT to this session's rendezvous socket found a
115
+ // live listener (the caller probes all windowed sessions once per snapshot). The adapter adds only its own
116
+ // channel check. claude: online iff the window is up AND its reclaude rendezvous socket has a live LISTENER
117
+ // (`socketLive` a connect that a live claude accepts and a stale socket FILE refuses; claude IGNORES the
118
+ // pane probe). codex: online iff the window is up AND a codex-ish process (`codex` by any
119
+ // name, or the `node` its CLI runs under) is live in the pane pid's DESCENDANT tree — NOT the pane's
120
+ // foreground command name (that is `bash`, the launch wrapper, even while the TUI renders — field-confirmed),
121
+ // and NOT the SHARED per-project app-server socket (it stays bound after a failed `--remote resume` dropped
122
+ // the pane back to the shell). A missing probe (tmux/ps couldn't report) is not-live. The 'starting' boot
123
+ // grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
124
+ liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
84
125
  // deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
85
- // control socket, which injects + submits the prompt and CONFIRMS the daemon accepted it (loud failure on a
86
- // missing/dead socket never a silent degradation). codex: JSON-RPC on the same app-server WebSocket the
126
+ // control socket OPTIMISTIC-after-liveness (loud failure when the socket is missing/dead or the write can't
127
+ // flush; once the reply line flushes to a live socket it returns ok, without waiting for an application ack —
128
+ // see replyViaSocket). codex: JSON-RPC on the same app-server WebSocket the
87
129
  // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
88
130
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
89
131
  // Returns ok=false with a reason that propagates to the API.
90
132
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
133
+ // --- materialize: clean (the inverse of write — [[harness-select]] prunes a deselected harness) ---
134
+ // clean is the EXACT inverse of materialize's per-harness write: SURGICALLY remove ONLY SpexCode's own
135
+ // artifacts — the managed contract block (sentinels), the generated shim file, the trust block, and the
136
+ // skill/agent files named in `arts` — never the user's surrounding prose, their other settings, or any .spec
137
+ // data. materialize calls it for every UNSELECTED harness, so dropping a harness from spexcode.json's
138
+ // `harnesses` prunes that harness's products on the next re-materialize.
139
+ clean(proj: string, arts: HarnessArtifacts): void
140
+ // the inverse of writeTrust: strip THIS project's spexcode trust block from the harness's global config.
141
+ // Codex removes its `~/.codex/config.toml` block; Claude is a no-op (it wrote none).
142
+ removeTrust(proj: string): void
143
+
91
144
  // the relaunch tail reopen() hands launch() to bring the SAME work back up. claude resumes the same
92
145
  // conversation (`--resume <id>`, the id we pinned at launch). codex's own thread id is un-pinnable on the
93
146
  // launch flag, so the BACKEND owns it: it `thread/start`s the thread and stores the id at launch, so reopen
@@ -97,12 +150,18 @@ export interface Harness {
97
150
  resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
98
151
  }
99
152
 
100
- // a prompt-dispatch outcome. ok=true ONLY when delivery is CONFIRMED (claude: the daemon ACCEPTED the prompt;
101
- // codex: app-server accepted `turn/start`). `error` carries a human-readable reason that propagates to the
102
- // API route (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
103
- // re-exports it for its existing importers.
153
+ // a prompt-dispatch outcome. ok=true means delivery is confirmed at the layer that harness proves it: claude at
154
+ // the TRANSPORT/liveness layer (the reply line flushed to a live rendezvous socket see replyViaSocket for why
155
+ // it is OPTIMISTIC-after-liveness, not a round-trip ack); codex at the application layer (the app-server accepted
156
+ // `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route (non-2xx)
157
+ // and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts re-exports it for
158
+ // its existing importers.
104
159
  export type DispatchResult = { ok: boolean; error?: string }
105
160
  export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
161
+ // the on-demand surface artifacts a materialize render wrote, by node NAME — so clean() knows EXACTLY which
162
+ // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
163
+ // also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
164
+ export type HarnessArtifacts = { skills: readonly string[]; agents: readonly string[] }
106
165
 
107
166
  // @@@ rendezvous control socket - claude's DETERMINISTIC, ONLY input path for PROMPTS to sessions WE launch.
108
167
  // sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
@@ -110,12 +169,58 @@ export type HarnessDeliveryRecord = { session: string; worktreePath?: string; ha
110
169
  // `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
111
170
  // and Enters can't be corrupted the way `tmux send-keys` was. The path is uniquely derived from the session id,
112
171
  // so we only ever address OUR OWN sockets (HARD ethics rule: never touch a session outside this product). It
113
- // lives in tmpdir tied to the claude process, so no extra lifecycle. liveness reads its existence (present while
114
- // claude is alive, gone once it exits); deliver writes to it. Exported because sessions.ts builds the launch env
115
- // var from it and best-effort sweeps it on close — but the liveness/delivery USE is the adapter's, below.
172
+ // lives in tmpdir tied to the claude process, so no extra lifecycle. liveness CONNECTS to it (a live LISTENER,
173
+ // not merely the file see rendezvousListening); deliver writes to it. Exported because sessions.ts builds the
174
+ // launch env var from it and best-effort sweeps it on close — but the liveness/delivery USE is the adapter's, below.
116
175
  export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
117
- export const codexAppServerSock = (dir = process.env.SPEXCODE_CODEX_SOCKET_DIR || tmpdir()) => join(dir, 'codex-app-server.sock')
118
- export const codexAppServerPid = (dir = process.env.SPEXCODE_CODEX_SOCKET_DIR || tmpdir()) => join(dir, 'codex-app-server.pid')
176
+
177
+ // @@@ rendezvousListening - the LISTENER check that IS claude's liveness truth ([[state]], [[harness-adapter]]).
178
+ // A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
179
+ // an unclean exit), so the old `existsSync(rvSock)` read a DEAD pane as `online` for as long as the stale file
180
+ // lingered — the incident's "dead pane stuck `working` for 30+ min". The honest signal is a live LISTENER:
181
+ // connect() to the socket. A real claude is accepting → connects; a stale file → ECONNREFUSED (instant); an
182
+ // absent file → ENOENT (instant). So the common cases cost no waiting; the short timeout only bounds the
183
+ // pathological file-present-but-listener-wedged case (then treated as not-live). Never throws.
184
+ export function rendezvousListening(id: string, timeoutMs = 800): Promise<boolean> {
185
+ return new Promise((resolve) => {
186
+ let settled = false
187
+ let c: ReturnType<typeof createConnection> | undefined
188
+ const done = (v: boolean) => {
189
+ if (settled) return
190
+ settled = true
191
+ clearTimeout(timer)
192
+ try { c?.destroy() } catch { /* */ }
193
+ resolve(v)
194
+ }
195
+ const timer = setTimeout(() => done(false), timeoutMs)
196
+ try { c = createConnection({ path: rvSock(id) }) } catch { return done(false) }
197
+ c.on('connect', () => done(true))
198
+ c.on('error', () => done(false)) // ECONNREFUSED (stale file) / ENOENT (gone) — both mean no live agent
199
+ })
200
+ }
201
+ // The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
202
+ // dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
203
+ // dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
204
+ // project (~111 chars) → `path must be shorter than SUN_LEN` + connect EINVAL, and the app-server never bound
205
+ // (Linux's 108 limit + shorter `/root` paths happened to fit; macOS did not). So the socket is
206
+ // `<socketBase>/spexcode-cx-<hash>.sock`, where `<hash>` is a short STABLE digest of the PROJECT identity — the
207
+ // `dir` (runtimeDir) the callers pass — so launch, liveness, and delivery all compute the IDENTICAL sock for a
208
+ // given project (the ONE-app-server-per-project invariant). This is UNCONDITIONAL on every platform (a short
209
+ // hashed path is strictly better everywhere — no darwin branch; platform differences stay at this path seam).
210
+ // `<socketBase>` = the `SPEXCODE_CODEX_SOCKET_DIR` override, else an OWNED per-uid subdir of the platform
211
+ // tmpdir (`spexcode-cx-<uid>`, created 0700) — NEVER bare tmpdir: codex (0.137+ field-confirmed) refuses to
212
+ // bind a unix socket directly in the shared sticky `/tmp` on a host with `fs.protected_regular=2` (EPERM), so
213
+ // the bare-tmpdir default failed every codex launch on a stock hardened Ubuntu out of the box (github#30),
214
+ // while the SAME codex binds fine in any owned subdirectory. Per-uid (not one shared `spexcode-cx`) so a
215
+ // second user on the box never lands in the first user's 0700 dir. The derivation GUARANTEES the dir exists
216
+ // (idempotent mkdir) so every consumer — launch bake, liveness connect, delivery, tests — shares one creation
217
+ // point. The `.pid`/`.log`/`.lock` files carry no sun_path limit and stay in `runtimeRoot`.
218
+ export const codexAppServerSock = (dir = runtimeRoot()) => {
219
+ const base = process.env.SPEXCODE_CODEX_SOCKET_DIR || join(tmpdir(), `spexcode-cx-${process.getuid?.() ?? 0}`)
220
+ mkdirSync(base, { recursive: true, mode: 0o700 })
221
+ return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
222
+ }
223
+ export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
119
224
 
120
225
  function shQuote(s: string): string {
121
226
  return `'${s.replace(/'/g, `'\\''`)}'`
@@ -127,31 +232,30 @@ function shQuote(s: string): string {
127
232
  const PKG = fileURLToPath(new URL('..', import.meta.url))
128
233
  const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
129
234
 
130
- const ACCEPT_TIMEOUT_MS = 2500
131
- // @@@ replyViaSocket - inject `text` as a prompt AND confirm the daemon ACCEPTED it (not mere write-success,
132
- // which is what silently masked dead dispatches before). The CLAUDE_BG_BACKEND=daemon rendezvous server sends
133
- // NO ack for an accepted reply, so we confirm via an IN-ORDER round-trip: we write `{type:reply}\n{type:repaint}\n`.
134
- // The daemon dispatches socket lines strictly in order and ENQUEUES the reply BEFORE it handles the repaint and
135
- // answers `{type:repaint-done}` so a `repaint-done` with NO preceding `reply-rejected` proves the reply was
136
- // processed. `repaint` is auth-exempt and always answers, so it's a reliable probe even against a future daemon
137
- // that gates `reply` behind auth (a gated reply emits `reply-rejected` FIRST). `reply-rejected`/`shutting-down`,
138
- // a connect/socket error, an early close, or no confirmation within ACCEPT_TIMEOUT_MS ALL resolve to a loud
139
- // failure with a specific reason. The forced repaint is a harmless redraw of the agent's OWN TUI. Never throws.
235
+ // @@@ replyViaSocket - OPTIMISTIC-after-liveness delivery: connect to the LIVE rendezvous socket and WRITE the
236
+ // `{type:reply}\n` line; once that line FLUSHES to the socket with no immediate transport error, the reply is on
237
+ // the wire and claude submits it the moment it yields the event loop — so we return ok:true right there. We do
238
+ // NOT wait for an application-level acceptance ack. The old code wrote a `{type:repaint}` probe after the reply
239
+ // and waited up to 2500ms for a `{type:repaint-done}` to CONFIRM the reply was processed; the ordering barrier is
240
+ // sound one-way (repaint-done delivered) but its ABSENCE is NOT proof of non-delivery a mid-turn/BUSY claude
241
+ // can't answer the probe within the wall, so a message that actually landed reported a COMMON, misleading false
242
+ // failure. The delivery confirmation therefore lives at the TRANSPORT/liveness layer, not the application layer:
243
+ // deliverViaRendezvous already gates on the socket's existence (liveness), and here the FREE, INSTANT transport
244
+ // signals a connect that throws, a socket 'error' (ECONNREFUSED / EPIPE), or a 'close' BEFORE the write flushes —
245
+ // are still reported as real failures (they cost no waiting). What we knowingly give up is detection of the narrow
246
+ // alive-but-wedged / reply-rejected / shutting-down cases: a rare silent drop, recoverable because the supervisor
247
+ // sees the worker never transition. That tradeoff is the deliberate contract now — see [[harness-adapter]].
248
+ // Never throws.
140
249
  function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
141
250
  return new Promise((resolve) => {
142
- let settled = false, buf = ''
251
+ let settled = false
143
252
  let c: ReturnType<typeof createConnection>
144
253
  const done = (r: DispatchResult) => {
145
254
  if (settled) return
146
255
  settled = true
147
- clearTimeout(timer)
148
256
  try { c?.destroy() } catch { /* */ }
149
257
  resolve(r)
150
258
  }
151
- const timer = setTimeout(
152
- () => done({ ok: false, error: `rendezvous socket gave no acceptance confirmation within ${ACCEPT_TIMEOUT_MS}ms` }),
153
- ACCEPT_TIMEOUT_MS,
154
- )
155
259
  try {
156
260
  c = createConnection({ path: sock })
157
261
  } catch (e) {
@@ -159,26 +263,15 @@ function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
159
263
  return
160
264
  }
161
265
  c.on('error', (e: NodeJS.ErrnoException) => done({ ok: false, error: `rendezvous socket connect failed: ${e?.code || String(e)}` }))
162
- c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was confirmed accepted' }))
163
- c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
164
- c.on('data', (chunk: Buffer) => {
165
- buf += chunk.toString('utf8')
166
- let i: number
167
- while ((i = buf.indexOf('\n')) >= 0) {
168
- const line = buf.slice(0, i); buf = buf.slice(i + 1)
169
- if (!line) continue
170
- let type: string | undefined
171
- try { type = JSON.parse(line)?.type } catch { continue } // ignore any non-JSON noise on the wire
172
- if (type === 'reply-rejected') return done({ ok: false, error: 'agent REJECTED the prompt (rendezvous reply-rejected — auth-gated daemon?)' })
173
- if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt not accepted' })
174
- if (type === 'repaint-done') return done({ ok: true }) // reply was enqueued in-order before this
175
- // heartbeat / state / other frames → keep waiting for the decisive repaint-done or a rejection.
176
- }
177
- })
266
+ c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was written' }))
267
+ // write the reply line; the write callback fires once it flushes to the live socket optimistic ok. An
268
+ // immediate transport 'error'/'close' beats the callback and reports the real failure (done is idempotent).
269
+ c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n', () => done({ ok: true })))
178
270
  })
179
271
  }
180
- // claude's deliver: fail loud BEFORE attempting the socket if it isn't there (a clearer message than a raw
181
- // connect error), exactly as the old sendKeys did, then inject + confirm via the rendezvous round-trip.
272
+ // claude's deliver: the pre-write LIVENESS gate — fail loud BEFORE attempting the socket if it isn't there (a
273
+ // clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent alive).
274
+ // Then inject the reply optimistically via replyViaSocket (no round-trip ack).
182
275
  function deliverViaRendezvous(id: string, text: string): Promise<DispatchResult> {
183
276
  const sock = rvSock(id)
184
277
  if (!existsSync(sock)) return Promise.resolve({ ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` })
@@ -233,8 +326,50 @@ export function activeTurnIdFromThread(readResult: unknown): string | null {
233
326
  return active?.id ?? null
234
327
  }
235
328
 
236
- export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_CODEX_CMD || 'codex --yolo', serverCmd = process.env.SPEXCODE_CODEX_SERVER_CMD || 'codex', dir = process.env.SPEXCODE_CODEX_SOCKET_DIR || runtimeRoot()): string {
237
- const sock = codexAppServerSock(dir)
329
+ // The app-server and the visible `--remote resume` TUI share ONE socket, so they MUST be the SAME codex
330
+ // install a version split across that socket breaks the thread/start→resume handoff (an app-server on one
331
+ // version creates a thread a differently-versioned resume can't find; an old-enough app-server can't serve
332
+ // `--remote unix://` at all). So `serverCmd` is DERIVED from the in-effect `codexCmd`'s binary (its first shell
333
+ // token, dropping args like `--yolo`) whenever it isn't explicitly forced: `<bin> app-server` then runs the
334
+ // SAME install as `<bin> --remote … resume`. Bare `codex` is NOT the default anymore — on a multi-install host
335
+ // (e.g. homebrew codex shadowing an nvm codex) a bare `codex` resolves via the login-shell PATH to a DIFFERENT
336
+ // binary than the launcher's, which is exactly the version-skew bug. `SPEXCODE_CODEX_SERVER_CMD` stays the
337
+ // explicit escape hatch (highest precedence). Caveat: if `codexCmd`'s first token is a WRAPPER script rather
338
+ // than codex itself, the derived `<wrapper> app-server` only works if the wrapper forwards to codex — the
339
+ // common direct-binary case (`codex …`, `/abs/codex --yolo`) is what this fixes.
340
+ export function codexBinary(codexCmd: string): string {
341
+ return codexCmd.trim().split(/\s+/)[0] || 'codex'
342
+ }
343
+ // codex >=0.142 adds `--dangerously-bypass-hook-trust` — run our OWN (vetted) dispatch hooks without a persisted
344
+ // trusted_hash. We PREFER it over reverse-engineering codexHookHash: that hash is pinned to one codex version's
345
+ // format and silently breaks on a bump (codex then skips ALL our hooks -> no Stop gate, no mark-active, sessions
346
+ // die undeclared). The flag is version-robust. But an OLDER codex HARD-ERRORS on the unknown flag (the whole
347
+ // app-server fails to boot), so we CAPABILITY-PROBE the binary once (`--help` grep) and only pass it when
348
+ // present; otherwise the writeCodexTrust hash path still stands in. Memoized — a per-binary constant.
349
+ const bypassProbe = new Map<string, boolean>()
350
+ export function codexSupportsBypassHookTrust(binary: string): boolean {
351
+ // explicit escape hatch (also what makes this deterministic in tests): force the capability on/off regardless
352
+ // of the binary — e.g. if the `--help` probe is unreliable on a wrapper, or to pin behaviour.
353
+ const env = process.env.SPEXCODE_CODEX_BYPASS_HOOK_TRUST
354
+ if (env !== undefined) return env === '1' || env === 'true'
355
+ const hit = bypassProbe.get(binary)
356
+ if (hit !== undefined) return hit
357
+ let ok = false
358
+ try { ok = execFileSync(binary, ['--help'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).includes('--dangerously-bypass-hook-trust') } catch { ok = false }
359
+ bypassProbe.set(binary, ok)
360
+ return ok
361
+ }
362
+ export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_CODEX_CMD || 'codex --yolo', serverCmd?: string, dir = runtimeRoot()): string {
363
+ const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
364
+ // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
365
+ // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
366
+ // thread — it was INERT there, the bug). Two thread paths carry it: (1) the BACKEND-owned `thread/start` sends
367
+ // `config.bypass_hook_trust` from codex-launch ([[harness-adapter]]); (2) the visible `--remote … resume` TUI,
368
+ // where codex's OWN client forwards this flag into its thread/start+thread/resume config — so a reopen in a
369
+ // fresh app-server (where codex-launch never runs) still trusts our hooks. Hence the flag lives on the resume
370
+ // TUI, never on the app-server invocation. Guarded against a double-flag when an env override already carries it.
371
+ const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
372
+ const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
238
373
  const pid = codexAppServerPid(dir)
239
374
  const log = join(dir, 'codex-app-server.log')
240
375
  const lock = join(dir, 'codex-app-server.lock')
@@ -244,27 +379,61 @@ export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_
244
379
  `pid=${shQuote(pid)}`,
245
380
  `log=${shQuote(log)}`,
246
381
  `lock=${shQuote(lock)}`,
382
+ // codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
383
+ // WE already hold the launcher's real cmd here (it drives the app-server + resume TUI + tuiBypass above), so
384
+ // pin it into the environment the codex-launch child inherits. Without this the child falls back to a bare
385
+ // `codex`, which on a multi-install box (e.g. an old Homebrew codex on PATH beside the launcher's newer one)
386
+ // probes the WRONG binary — deciding "no --dangerously-bypass-hook-trust support" and silently dropping the
387
+ // thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
388
+ `export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
247
389
  'mkdir -p "$dir"',
248
- '(',
249
- ' flock 9',
250
- ' if [ -S "$sock" ] && [ -s "$pid" ] && ! kill -0 "$(cat "$pid")" 2>/dev/null; then rm -f "$sock"; fi',
251
- ' if [ ! -S "$sock" ]; then',
252
- // 9>&- : do NOT let the long-lived app-server inherit fd 9 (the flock fd). An flock is held until
253
- // EVERY fd on its open file description is closed; if the daemon keeps fd 9 open it pins the lock
254
- // forever, so every later launcher blocks on `flock 9` and never reaches the thread-owning step
255
- // (the pane stays at the shell, no TUI, no thread). </dev/null detaches its stdin from the pane so
256
- // it can't fight the TUI for the tty.
257
- ` ${serverCmd} app-server --listen unix://"$sock" >"$log" 2>&1 9>&- </dev/null &`,
258
- ' echo $! > "$pid"',
259
- ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
260
- ' fi',
261
- ') 9>"$lock"',
262
- // BACKEND owns the thread: `codex-launch` does thread/start { cwd = this worktree } on the shared
263
- // per-project app-server, stores the new thread id on the governed record (SPEXCODE_SESSION_ID), and
264
- // fires the launch prompt as the first turn — materializing the rollout. It prints the thread id, which
265
- // the visible TUI then RESUMES (the rollout persists on disk), rendering it natively. "$@" is the prompt.
266
- `tid=$(${SPEX} codex-launch "$sock" "$PWD" "$@")`,
267
- `exec ${codexCmd} --remote unix://"$sock" resume "$tid"`,
390
+ 'mkdir -p -m 700 "$(dirname "$sock")"', // the socket base (owned tmp subdir or the SPEXCODE_CODEX_SOCKET_DIR override) — re-created here because a tmp cleaner may have wiped it since the bake; NEVER bare /tmp (codex EPERMs binding there on hardened hosts, github#30)
391
+ // self-heal: the pre-fix flock design left an orphaned `codex-app-server.lock` FILE; the mkdir mutex now
392
+ // uses `"$lock.d"`, so drop that dead residue on already-run deployments (harmless if absent).
393
+ 'rm -f "$lock"',
394
+ // POSIX-portable mutex: mkdir is atomic on every POSIX fs, so it serializes the check-and-start with NO
395
+ // dependency on util-linux `flock` (absent on macOS where the old flock path failed the whole app-server
396
+ // bootstrap, leaving the pane at the shell). Spin on `mkdir "$lock.d"` with a bounded wait; after ~10s
397
+ // (200 * 0.05s, safely above the ~5s socket-wait a legit holder needs) treat the dir as orphaned by a dead
398
+ // launcher and clear it, so a stale lock can never deadlock a launch. Held ONLY across the check-and-start,
399
+ // released immediately after. Unlike flock (held until every fd on its open file description closes) a mkdir
400
+ // lock has no inherited-fd hazard, so the long-lived daemon can't pin it — no fd-9 gymnastics needed.
401
+ 'lockd="$lock.d"',
402
+ '_lk=0',
403
+ 'until mkdir "$lockd" 2>/dev/null; do',
404
+ ' _lk=$((_lk+1)); [ "$_lk" -ge 200 ] && { rm -rf "$lockd" 2>/dev/null; _lk=0; }',
405
+ ' sleep 0.05',
406
+ 'done',
407
+ 'if [ -S "$sock" ] && [ -s "$pid" ] && ! kill -0 "$(cat "$pid")" 2>/dev/null; then rm -f "$sock"; fi',
408
+ 'if [ ! -S "$sock" ]; then',
409
+ // The app-server is a per-PROJECT daemon SHARED across every worktree's threads, so it must run in a STABLE
410
+ // cwd — the runtime dir "$dir", NOT the launch.sh's transient worktree. A daemon started inside a worktree
411
+ // keeps that worktree as its process cwd for its whole life; when the session closes and the worktree is
412
+ // removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
413
+ // `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
414
+ // until the daemon is killed. Running it from "$dir" (which never gets deleted) makes it deletion-proof.
415
+ // exec so $! is the daemon itself; </dev/null detaches its stdin from the pane so it can't fight the TUI.
416
+ ` ( cd "$dir" && exec ${server} app-server --listen unix://"$sock" >"$log" 2>&1 </dev/null ) &`,
417
+ ' echo $! > "$pid"',
418
+ ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
419
+ 'fi',
420
+ 'rmdir "$lockd" 2>/dev/null',
421
+ // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
422
+ // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
423
+ // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
424
+ // does thread/start { cwd = this worktree } on the shared per-project app-server, stores the new id on the
425
+ // governed record (SPEXCODE_SESSION_ID), and fires the tail as the FIRST turn, materializing the rollout.
426
+ // Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
427
+ // rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
428
+ // literal "--resume" marker — the discriminator is unambiguous. codex-launch only prints an id once its
429
+ // rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
430
+ `if [ "$1" = "--resume" ]; then`,
431
+ ` tid=$2`,
432
+ `else`,
433
+ ` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@") || exit 1`,
434
+ `fi`,
435
+ `[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
436
+ `exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`,
268
437
  ].join('\n')
269
438
  return `bash -lc ${shQuote(script)} spexcode-codex`
270
439
  }
@@ -374,7 +543,7 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
374
543
  // carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
375
544
  // fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
376
545
  // Never throws.
377
- export function codexStartThread(sock: string, cwd?: string): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
546
+ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
378
547
  return new Promise((resolve) => {
379
548
  const conn: Socket = createConnection(sock)
380
549
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
@@ -395,7 +564,18 @@ export function codexStartThread(sock: string, cwd?: string): Promise<{ ok: true
395
564
  let m: JsonRpc
396
565
  try { m = JSON.parse(json) } catch { return }
397
566
  if (m.error) return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
398
- if (m.id === 1 && m.result) { send({ method: 'initialized', params: {} }); return send({ id: 2, method: 'thread/start', params: cwd ? { cwd } : {} }) }
567
+ if (m.id === 1 && m.result) {
568
+ send({ method: 'initialized', params: {} })
569
+ // thread/start's `config` is the per-request override map the app-server reads (config_manager reads
570
+ // `request_overrides["bypass_hook_trust"]`) — the ONLY channel that reaches the thread config; the
571
+ // `--dangerously-bypass-hook-trust` flag on the `codex app-server` invocation is INERT (the app-server
572
+ // never reads it for a thread), so a BACKEND-owned thread must carry the bypass here, exactly as codex's
573
+ // own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
574
+ // stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
575
+ // the flag uses writeCodexTrust's hash and never sees this key).
576
+ const params = { ...(cwd ? { cwd } : {}), ...(bypassHookTrust ? { config: { bypass_hook_trust: true } } : {}) }
577
+ return send({ id: 2, method: 'thread/start', params })
578
+ }
399
579
  if (m.id === 2 && m.result) {
400
580
  const tid = (m.result as { thread?: { id?: string } })?.thread?.id
401
581
  return tid ? done({ ok: true, threadId: tid }) : done({ ok: false, error: 'codex thread/start returned no thread id' })
@@ -506,6 +686,35 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
506
686
  return sendCodexAppServerTurn(sock, threadId, text, cwd)
507
687
  }
508
688
 
689
+ // @@@ codex rollout on disk - the visible TUI resumes a thread via `codex --remote resume <tid>`, which reads
690
+ // the thread's ROLLOUT FILE (`<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<ts>-<tid>.jsonl`) — so a thread the
691
+ // TUI can render is exactly one whose rollout exists on disk. VERIFIED live (real codex 0.142.5): `thread/start`
692
+ // ALONE writes NO rollout — only the first fired turn materializes it; and a FRESHLY-spawned app-server accepts
693
+ // thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
694
+ // rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
695
+ // found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
696
+ const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
697
+ // does a rollout file for this thread id exist yet? Rollouts are grouped by date, so scan only the newest few
698
+ // day-dirs (lexical order = chronological on zero-padded YYYY/MM/DD) — clock-agnostic and cheap on a big history.
699
+ export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
700
+ const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
701
+ const days: string[] = []
702
+ for (const y of kids(root)) { for (const m of kids(join(root, y))) { for (const d of kids(join(root, y, m))) { days.push(join(root, y, m, d)); if (days.length >= 3) break } if (days.length >= 3) break } if (days.length >= 3) break }
703
+ return days.some((d) => kids(d).some((f) => f.includes(threadId)))
704
+ }
705
+ // poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
706
+ // caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
707
+ // exceed launch.sh's fast-fail threshold so a genuine failure exits PAST it — the retry loop then treats it as a
708
+ // real end, not a daemon race, and never sprays fresh (duplicate-prompt) threads.
709
+ export async function waitForCodexRollout(threadId: string, timeoutMs = 20000): Promise<boolean> {
710
+ const deadline = Date.now() + timeoutMs
711
+ for (;;) {
712
+ if (codexRolloutExists(threadId)) return true
713
+ if (Date.now() >= deadline) return false
714
+ await new Promise((r) => setTimeout(r, 250))
715
+ }
716
+ }
717
+
509
718
  // codex's deliver: use the Codex app-server JSON-RPC channel that also powers rich clients, never TUI typing.
510
719
  // The visible TUI is launched against the same project app-server Unix socket, so this injects into the same
511
720
  // thread the pane is showing — steering an in-progress turn or starting one if idle. A missing captured thread
@@ -544,6 +753,29 @@ export function writeManagedBlock(file: string, body: string, comment: readonly
544
753
  writeFileSync(file, cur)
545
754
  }
546
755
 
756
+ // the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
757
+ // leaving every other byte of the user's file intact. When deleteIfEmpty and nothing but whitespace remains,
758
+ // remove the file — it was WHOLLY ours (e.g. a CLAUDE.md that carried only the generated contract block). Same
759
+ // comment-style parameter so ONE primitive un-writes every managed file. No-op when the file/block is absent.
760
+ export function removeManagedBlock(file: string, comment: readonly [string, string] = ['<!-- ', ' -->'], deleteIfEmpty = false): void {
761
+ if (!existsSync(file)) return
762
+ const [open, close] = comment
763
+ const START = `${open}spexcode:start${close}`
764
+ const END = `${open}spexcode:end${close}`
765
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
766
+ const re = new RegExp(`\\n*${esc(START)}[\\s\\S]*?${esc(END)}\\n*`)
767
+ const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
768
+ if (!re.test(cur)) return
769
+ // remove ONLY our block plus the blank lines writeManagedBlock inserted around it; do NOT normalize the
770
+ // user's OWN whitespace elsewhere — this must leave every other byte intact so it is a faithful INVERSE of
771
+ // writeManagedBlock's append. A global `\n{3,}→\n\n` collapse used to sit here and mutated pre-existing
772
+ // blank-line runs in the user's file, which broke the private⇄default round-trip ([[private-overlay]]):
773
+ // default→private→default left a spurious one-line diff on a .gitignore that had internal blank lines.
774
+ const out = cur.replace(re, '\n').replace(/^\n+/, '')
775
+ if (deleteIfEmpty && !out.trim()) { rmSync(file, { force: true }); return }
776
+ writeFileSync(file, out)
777
+ }
778
+
547
779
  // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
548
780
  // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
549
781
  // the cli-needing handlers + the gate's `spex materialize`.
@@ -576,10 +808,41 @@ export function codexHookHash(snakeEvent: string, command: string, timeout = 600
576
808
  return 'sha256:' + createHash('sha256').update(JSON.stringify(canon(obj))).digest('hex')
577
809
  }
578
810
 
579
- // additively stamp directory + per-hook trust into the user's GLOBAL ~/.codex/config.toml so a user-self-
580
- // launched codex skips the trust prompts. Scoped to THIS project path; replaces our own prior block (between
581
- // sentinels) idempotently; never touches the user's other config. CODEX_HOME respected for testability.
582
- function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): void {
811
+ // @@@ stripCodexTrustFor - remove EVERY prior definition of THIS project's codex trust from a config.toml body,
812
+ // in ANY form: our own sentinel block (whatever past format its comments used), a BARE `[projects."<proj>"]`
813
+ // table (codex AUTO-writes one the moment it trusts a folder interactively/`exec` — NOT sentinel-wrapped), and
814
+ // any `[hooks.state."<hooksJson>:…"]` tables. This is what makes the UNCONDITIONAL write duplicate-SAFE and
815
+ // SELF-HEALING: codex REFUSES to load a config.toml with a duplicate key ("duplicate key"), so a sentinel-only
816
+ // replace (the old behaviour) that missed a pre-existing bare/old block APPENDED a second `[projects."<proj>"]`
817
+ // and took codex fully OFFLINE (the real cause of the public-vps outage). It is TABLE-scoped and STRING-compared
818
+ // (no regex escaping of the path), so other projects' trust, the shared parent tables (`[projects]`,
819
+ // `[hooks.state]`), and every other config key are untouched; a skipped table's body ends at the next header,
820
+ // blank, or comment, so a user comment attached to a following table is preserved.
821
+ function stripCodexTrustFor(cur: string, proj: string, hooksJson: string): string {
822
+ const projHeader = `[projects."${proj}"]`
823
+ const hooksPrefix = `[hooks.state."${hooksJson}:`
824
+ const out: string[] = []
825
+ let skip = false
826
+ for (const line of cur.split('\n')) {
827
+ const t = line.trim()
828
+ const isHeader = /^\[\[?/.test(t) // a TOML table / array-of-tables header
829
+ if (skip) { if (t === '' || t.startsWith('#') || isHeader) skip = false; else continue } // end THIS table's body
830
+ if (isHeader && (t === projHeader || t.startsWith(hooksPrefix))) { skip = true; continue }
831
+ if (t === `# spexcode:trust:${proj} (managed — do not edit)` || t === `# spexcode:trust:end:${proj}`) continue
832
+ out.push(line)
833
+ }
834
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '')
835
+ }
836
+
837
+ // additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
838
+ // `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
839
+ // self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
840
+ // already-reviewed (no "Hooks need review" prompt on a persistent resume — see writeTrust). ALL prior
841
+ // definitions of this project's trust (ours, bare, or old-format) are STRIPPED first, so the write can never
842
+ // leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
843
+ // Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
844
+ // (`events` may be empty for a trust-only stamp in tests.)
845
+ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): void {
583
846
  const home = process.env.CODEX_HOME || join(homedir(), '.codex')
584
847
  const file = join(home, 'config.toml')
585
848
  const hooksJson = join(proj, '.codex', 'hooks.json')
@@ -589,13 +852,79 @@ function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: st
589
852
  lines.push(`[hooks.state."${hooksJson}:${snake}:0:0"]`, `trusted_hash = "${codexHookHash(snake, cmdFor(e))}"`)
590
853
  }
591
854
  const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
592
- const esc = proj.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
593
- let cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
594
- const re = new RegExp(`# spexcode:trust:${esc} \\(managed[\\s\\S]*?# spexcode:trust:end:${esc}`)
595
- if (re.test(cur)) cur = cur.replace(re, blk)
596
- else cur = cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${blk}\n` : `${blk}\n`
855
+ const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
597
856
  if (!existsSync(home)) mkdirSync(home, { recursive: true })
598
- writeFileSync(file, cur)
857
+ writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
858
+ }
859
+
860
+ // the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
861
+ // removal writeCodexTrust does before it writes, so uninstall fully clears our trust (sentinel, bare, and
862
+ // hooks.state) and can never leave a half-block. No-op when the file/nothing-of-ours is absent (so it never
863
+ // rewrites/normalizes a config that carries none of our trust). CODEX_HOME respected for testability.
864
+ function removeCodexTrust(proj: string): void {
865
+ const home = process.env.CODEX_HOME || join(homedir(), '.codex')
866
+ const file = join(home, 'config.toml')
867
+ if (!existsSync(file)) return
868
+ const hooksJson = join(proj, '.codex', 'hooks.json')
869
+ const cur = readFileSync(file, 'utf8')
870
+ if (!cur.includes(`[projects."${proj}"]`) && !cur.includes(`[hooks.state."${hooksJson}:`) &&
871
+ !cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`)) return
872
+ const cleaned = stripCodexTrustFor(cur, proj, hooksJson)
873
+ writeFileSync(file, cleaned ? `${cleaned}\n` : '')
874
+ }
875
+
876
+ // @@@ cleanHarness - the shared clean: the inverse of materialize's per-harness write, expressed PURELY
877
+ // through the adapter's own path methods so it can never drift from what write put there. Each step is
878
+ // surgical, gated on a SpexCode identity stamp: the contract files carry the managed-block sentinels; the shim
879
+ // is a generated file whose command line names our `dispatch.sh`; the trust is a sentinel-delimited config
880
+ // block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
881
+ // blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
882
+ // a sibling skill/agent the user added, and NEVER any .spec data.
883
+ function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
884
+ for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], true)
885
+ const shim = h.shimFile(proj)
886
+ if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
887
+ const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
888
+ if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh')) rmSync(anchor, { force: true })
889
+ h.removeTrust(proj)
890
+ const sd = h.skillDir(proj)
891
+ if (sd) for (const n of arts.skills) rmSync(join(sd, n), { recursive: true, force: true })
892
+ const ad = h.agentDir(proj)
893
+ if (ad) for (const n of arts.agents) rmSync(join(ad, `${n}.md`), { force: true })
894
+ }
895
+
896
+ // ---------------------------------------------------------------------------------------------------------
897
+ // codex per-session liveness signal — a codex process live in the pane's DESCENDANT tree, NOT the pane's
898
+ // foreground command name, and NOT the shared app-server socket.
899
+
900
+ // @@@ paneTreeRunsCodex - the codex TUI is alive iff a codex-ish process is live SOMEWHERE in the launch
901
+ // pane's descendant process tree. The pane's FOREGROUND name is NOT the signal: the pane runs
902
+ // `bash <launch.sh>` → `bash -lc <codex script>` → node (the codex CLI) → the vendored `codex` binary, and
903
+ // tmux's `pane_current_command` reports the OUTERMOST of those — `bash` — for the entire life of a healthy,
904
+ // rendering TUI (field-confirmed on macmini and Linux). So "foreground == codex" false-read every live codex
905
+ // as offline, and the earlier sock-presence check false-read a dead one as online (the SHARED per-project
906
+ // app-server socket survives a failed `--remote resume`). The honest shape test: HEALTHY = codex (by whatever
907
+ // name — `codex`, the vendored musl binary, or the `node` its CLI runs under) exists among the pane pid's
908
+ // descendants; FAILED = the launch script's bounded retries exhausted, everything under the pane exited, and
909
+ // the pane sits at the bare shell — no codex/node anywhere below it. The walk is over ONE whole-box
910
+ // pid→(ppid, comm) snapshot the caller took (a single `ps` for the whole session list); missing probe data
911
+ // (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
912
+ // may not yet contain codex — as 'starting', not 'offline'.
913
+ const CODEXISH = /^(codex|node)/i // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
914
+ export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
915
+ if (!pane?.panePid || !pane.procs?.size) return false
916
+ const kids = new Map<number, number[]>()
917
+ for (const [pid, p] of pane.procs) {
918
+ const arr = kids.get(p.ppid); if (arr) arr.push(pid); else kids.set(p.ppid, [pid])
919
+ }
920
+ const stack = [...(kids.get(pane.panePid) ?? [])] // descendants only — the pane pid itself is the shell
921
+ while (stack.length) {
922
+ const pid = stack.pop()!
923
+ const comm = pane.procs.get(pid)?.comm ?? ''
924
+ if (CODEXISH.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
925
+ const c = kids.get(pid); if (c) stack.push(...c)
926
+ }
927
+ return false
599
928
  }
600
929
 
601
930
  // ---------------------------------------------------------------------------------------------------------
@@ -604,21 +933,34 @@ function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: st
604
933
  const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
605
934
  const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
606
935
 
936
+ // the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
937
+ // launchCmd and baseCmd so the two never diverge: `cmd` override wins, else ambient env→config→default.
938
+ const claudeBaseCmd = (cmd?: string) => cmd || process.env.SPEXCODE_CLAUDE_CMD || readConfig(mainCheckout()).sessions?.claudeCmd || 'claude --dangerously-skip-permissions'
939
+ const codexBaseCmd = (cmd?: string) => cmd || process.env.SPEXCODE_CODEX_CMD || readConfig(mainCheckout()).sessions?.codexCmd || 'codex --yolo'
940
+
607
941
  export const claudeHarness: Harness = {
608
942
  id: 'claude',
609
943
  events: CLAUDE_EVENTS,
610
944
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
611
945
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
612
- launchCmd: () => process.env.SPEXCODE_CLAUDE_CMD || readConfig(mainCheckout()).sessions?.claudeCmd || 'claude --dangerously-skip-permissions',
946
+ launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
947
+ baseCmd: claudeBaseCmd,
613
948
  sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
614
949
  sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
615
950
  shimFile: (proj) => join(proj, '.claude', 'settings.json'),
951
+ worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
616
952
  contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
617
953
  skillDir: (proj) => join(proj, '.claude', 'skills'),
954
+ agentDir: (proj) => join(proj, '.claude', 'agents'),
618
955
  shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
619
956
  writeTrust: () => { /* Claude relies on folder-trust — nothing to write */ },
957
+ removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
958
+ clean(proj, arts) { cleanHarness(this, proj, arts) },
620
959
  slashCommands: claudeSlashCommands,
621
- liveness: (rec, tmuxAlive) => (tmuxAlive && existsSync(rvSock(rec.session)) ? 'online' : 'offline'),
960
+ // online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
961
+ // the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
962
+ // dead-pane-reads-working bug). See rendezvousListening.
963
+ liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
622
964
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
623
965
  resumeArg: (rec) => `--resume ${rec.session}`,
624
966
  }
@@ -628,7 +970,8 @@ export const codexHarness: Harness = {
628
970
  events: CODEX_EVENTS,
629
971
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
630
972
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
631
- launchCmd: (id, runtimeDir) => codexLaunchCommand(id, process.env.SPEXCODE_CODEX_CMD || readConfig(mainCheckout()).sessions?.codexCmd, undefined, runtimeDir ?? runtimeRoot()), // env→config→default; ONE app-server per PROJECT
973
+ launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
974
+ baseCmd: codexBaseCmd,
632
975
  sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
633
976
  sessionEnvVar: 'CODEX_THREAD_ID',
634
977
  // Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
@@ -639,14 +982,50 @@ export const codexHarness: Harness = {
639
982
  // per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
640
983
  // cwd, so one shared shim serves every worktree.
641
984
  shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
985
+ // a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
986
+ // the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
987
+ // root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
988
+ // was never built). Its content is ignored (the rewrite reads the root's shim above), so it is a pure anchor.
989
+ // Only for a genuine worktree: on the main checkout, shimFile already wrote `.codex/hooks.json` there.
990
+ worktreeHookAnchor: (proj) => (mainCheckout(proj) === proj ? null : join(proj, '.codex', 'hooks.json')),
642
991
  contractFiles: (proj) => [join(proj, 'AGENTS.md')],
643
992
  skillDir: (proj) => join(proj, '.codex', 'skills'),
993
+ agentDir: () => null, // codex has no file-discovered agent-definition primitive — materialize skips it
644
994
  shim: (dispatch, spex) => buildShim('codex', CODEX_EVENTS, dispatch, spex),
995
+ // Write the FULL codex trust — BOTH tiers, UNCONDITIONALLY — because `bypass_hook_trust` covers neither on
996
+ // the dispatched-worker path:
997
+ // (1) PROJECT trust (`[projects."<mainCheckout>"] trust_level = "trusted"`) ENABLES the project config
998
+ // layer — the precondition for codex to DISCOVER our hooks AT ALL. codex-rs `get_layers` drops a
999
+ // disabled (untrusted) project layer BEFORE hook discovery runs, and bypass_hook_trust is read only
1000
+ // AFTER, per-handler — so it can NEVER enable a layer. A dispatched worker's app-server does NOT
1001
+ // auto-trust the project (only the interactive TUI / `codex exec` approval flow does), so without this
1002
+ // an untrusted worktree thread fires ZERO hooks ("Project-local config, hooks … are disabled until the
1003
+ // project is trusted").
1004
+ // (2) per-HOOK trust (the reverse-engineered `trusted_hash` blocks — codexHookHash) marks each hook Trusted
1005
+ // so it is NOT "new or changed". This is REQUIRED even though the launch carries
1006
+ // `--dangerously-bypass-hook-trust`: our visible TUI attaches to the backend-owned thread via `codex …
1007
+ // resume <tid>`, and codex-rs FORCES the startup hook-review prompt on a PERSISTENT RESUME regardless of
1008
+ // the flag (`bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume`,
1009
+ // tui/src/lib.rs) — an untrusted/modified hook (no matching hash) leaves the worker WEDGED at an
1010
+ // interactive "Hooks need review" menu. Matching hashes make review_needed_count == 0, so codex skips
1011
+ // the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
1012
+ // as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
1013
+ // thread still runs the hooks); it does not REPLACE the hashes here.
645
1014
  writeTrust: (proj, cmdFor) => writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor),
1015
+ // trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
1016
+ removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
1017
+ clean(proj, arts) { cleanHarness(this, proj, arts) },
646
1018
  slashCommands: codexSlashCommands,
647
- liveness: (rec, tmuxAlive, runtimeDir) => (tmuxAlive && existsSync(codexAppServerSock(runtimeDir)) ? 'online' : 'offline'),
1019
+ // online iff the tmux window is up AND a codex-ish process is live in the pane pid's DESCENDANT tree — see
1020
+ // paneTreeRunsCodex. NOT the pane's foreground command (that is `bash`, the launch wrapper, even while the
1021
+ // TUI renders — the field-confirmed false-OFFLINE) and NOT the app-server socket (SHARED per-project, it
1022
+ // survives a failed `--remote resume` — the earlier false-ONLINE).
1023
+ liveness: (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && paneTreeRunsCodex(pane) ? 'online' : 'offline'),
648
1024
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
649
- resumeArg: (rec) => (rec.harnessSessionId ? `resume ${rec.harnessSessionId}` : ''), // owned thread id → codex `resume <id>` (SAME conversation); none relaunch FRESH
1025
+ // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
1026
+ // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
1027
+ // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
1028
+ resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
650
1029
  }
651
1030
 
652
1031
  // every adapter — materialize iterates this to render each harness's artifacts in one pass.
@@ -662,3 +1041,30 @@ export function harnessById(id: string): Harness {
662
1041
  if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
663
1042
  return h
664
1043
  }
1044
+
1045
+ // --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
1046
+ // a launcher = a `{ harness, cmd }` pair in spexcode.json's `sessions.launchers`, keyed by a human-chosen name.
1047
+ // harness defaults to claude. resolveLauncher throws fail-loud on an unknown name (a session must never
1048
+ // silently launch under the wrong auth) and validates the harness id.
1049
+ export type Launcher = { name: string; harness: string; cmd: string }
1050
+
1051
+ // the configured launchers, as a stable name-sorted list (for the dashboard dropdown + the CLI). Empty when a
1052
+ // project configured none — the caller then falls back to the unnamed harness pick.
1053
+ export function launcherList(root = mainCheckout()): Launcher[] {
1054
+ const m = readConfig(root).sessions?.launchers || {}
1055
+ return Object.keys(m).sort().map((name) => ({ name, harness: m[name].harness || defaultHarness.id, cmd: m[name].cmd }))
1056
+ }
1057
+
1058
+ // the configured default launcher NAME ([[launcher-select]]) — the profile `spex new`/a dropdown pick with no
1059
+ // explicit choice resolves. '' when none configured. The dashboard reads this to PRE-SELECT its New-Session
1060
+ // dropdown to match the CLI/config default, so the two surfaces agree on which launcher a bare create uses.
1061
+ export function defaultLauncher(root = mainCheckout()): string {
1062
+ return readConfig(root).sessions?.defaultLauncher || ''
1063
+ }
1064
+
1065
+ export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
1066
+ const l = readConfig(root).sessions?.launchers?.[name]
1067
+ if (!l || !l.cmd) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
1068
+ harnessById(l.harness || defaultHarness.id) // validate the harness id fail-loud
1069
+ return { name, harness: l.harness || defaultHarness.id, cmd: l.cmd }
1070
+ }