spexcode 0.4.1 → 0.5.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 (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/claude-headless.ts +271 -0
  8. package/spec-cli/src/cli.ts +112 -38
  9. package/spec-cli/src/client.ts +42 -11
  10. package/spec-cli/src/doctor.ts +174 -36
  11. package/spec-cli/src/gateway-auth.ts +194 -0
  12. package/spec-cli/src/gateway-hub.ts +360 -0
  13. package/spec-cli/src/gateway.ts +27 -19
  14. package/spec-cli/src/git.ts +12 -5
  15. package/spec-cli/src/graph.ts +54 -43
  16. package/spec-cli/src/graphStream.ts +265 -34
  17. package/spec-cli/src/guide.ts +115 -56
  18. package/spec-cli/src/harness-select.ts +20 -6
  19. package/spec-cli/src/harness.ts +295 -39
  20. package/spec-cli/src/help.ts +68 -28
  21. package/spec-cli/src/host.ts +610 -0
  22. package/spec-cli/src/identity-presets.d.ts +13 -0
  23. package/spec-cli/src/identity-presets.js +128 -0
  24. package/spec-cli/src/index.ts +158 -87
  25. package/spec-cli/src/init.ts +53 -25
  26. package/spec-cli/src/layout.ts +6 -5
  27. package/spec-cli/src/lint.ts +25 -121
  28. package/spec-cli/src/login-page.ts +12 -5
  29. package/spec-cli/src/materialize.ts +39 -13
  30. package/spec-cli/src/mentions.ts +16 -11
  31. package/spec-cli/src/message-stream.ts +147 -0
  32. package/spec-cli/src/opencode-headless.ts +95 -0
  33. package/spec-cli/src/opencode.ts +145 -0
  34. package/spec-cli/src/pi-harness.ts +124 -0
  35. package/spec-cli/src/pi-headless.ts +195 -0
  36. package/spec-cli/src/project-identity.ts +73 -0
  37. package/spec-cli/src/pty-bridge.ts +321 -521
  38. package/spec-cli/src/pty-helper.mjs +86 -0
  39. package/spec-cli/src/reaper.ts +16 -8
  40. package/spec-cli/src/reviewSnapshot.ts +23 -0
  41. package/spec-cli/src/reviews.ts +347 -0
  42. package/spec-cli/src/session-timeline.ts +14 -0
  43. package/spec-cli/src/sessions.ts +211 -75
  44. package/spec-cli/src/shim-runtime.ts +169 -0
  45. package/spec-cli/src/slash-commands.ts +76 -0
  46. package/spec-cli/src/source-files.ts +79 -0
  47. package/spec-cli/src/specs.ts +12 -7
  48. package/spec-cli/src/supervise.ts +37 -15
  49. package/spec-cli/src/tree.ts +8 -8
  50. package/spec-cli/src/uninstall.ts +56 -23
  51. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  52. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  53. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  54. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  55. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  56. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  58. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  59. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  60. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  62. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  63. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  64. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  65. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  66. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  67. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  68. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  69. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  70. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  71. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  72. package/spec-cli/templates/spec/project/spec.md +6 -5
  73. package/spec-cli/templates/spexcode.json +10 -2
  74. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  75. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
  76. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  77. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  78. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  79. package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
  80. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  81. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  82. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  83. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
  84. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
  85. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  86. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  87. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  88. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  89. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  90. package/spec-dashboard/dist/index.html +10 -10
  91. package/spec-dashboard/src/reviewFilters.js +279 -0
  92. package/spec-dashboard/src/reviewQuery.js +174 -0
  93. package/spec-dashboard/src/session.js +167 -0
  94. package/spec-eval/src/cli.ts +49 -28
  95. package/spec-eval/src/humanok.ts +1 -1
  96. package/spec-eval/src/matrix.ts +693 -0
  97. package/spec-eval/src/sessioneval.ts +726 -66
  98. package/spec-cli/src/migrate-table.ts +0 -408
  99. package/spec-cli/src/migrate.ts +0 -386
  100. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  108. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  109. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  110. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  111. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -6,7 +6,12 @@ import { createConnection, type Socket } from 'node:net'
6
6
  import { execFile, execFileSync } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
8
  import { fileURLToPath } from 'node:url'
9
- import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
9
+ import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands, type SlashCommand } from './slash-commands.js'
10
+ import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
11
+ import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
12
+ import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js'
13
+ import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
14
+ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
10
15
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
11
16
  import { git } from './git.js'
12
17
 
@@ -22,7 +27,7 @@ import { git } from './git.js'
22
27
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
23
28
  // (materialize writes every harness's artifacts).
24
29
 
25
- export type HarnessId = 'claude' | 'codex'
30
+ export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'opencode-headless' | 'pi-headless'
26
31
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
27
32
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
28
33
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
@@ -39,6 +44,12 @@ export type PaneProbe = { panePid?: number; procs?: ProcTable; pidAlive?: boolea
39
44
 
40
45
  export interface Harness {
41
46
  readonly id: HarnessId
47
+ // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
48
+ // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
49
+ readonly headless: boolean
50
+ // whether this harness persists a native event stream that the console may expose as an optional
51
+ // full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
52
+ readonly messageStream: boolean
42
53
  // the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
43
54
  // Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
44
55
  // idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
@@ -54,7 +65,6 @@ export interface Harness {
54
65
  // instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
55
66
  // is data on the adapter, not an `if (codex)` in sessions.ts.
56
67
  readonly paneTitleIsSelfSummary: boolean
57
-
58
68
  // --- launch / sessionId ---
59
69
  // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
60
70
  // visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
@@ -101,16 +111,18 @@ export interface Harness {
101
111
  // ONLY place agent-surface divergence lives (the skillDir analog). Claude reads .claude/agents/<name>.md;
102
112
  // Codex has no file-discovered agent-definition primitive, so it returns null and materialize skips it.
103
113
  agentDir(proj: string): string | null
104
- // the shim payload: the settings/hooks JSON binding every event the dispatcher (harness id baked in), and
105
- // the per-event command string (shared with the trust writer so they hash identically).
106
- shim(dispatch: string, spex: string): { json: string; cmd: (e: string) => string }
114
+ // the shim payload: `content` is whatever artifact THIS harness auto-discovers to wire every event to the
115
+ // dispatcher (harness id baked in) a settings/hooks JSON for claude/codex, a generated event-bus PLUGIN
116
+ // for opencode, a generated TypeScript EXTENSION for pi plus the per-event command string (shared with
117
+ // the trust writer so they hash identically).
118
+ shim(dispatch: string, spex: string): { content: string; cmd: (e: string) => string }
107
119
  // make a dispatched/self-launched agent run the hooks with zero prompts. Codex writes PROJECT trust — and, on
108
120
  // a binary without `--dangerously-bypass-hook-trust`, per-hook trusted_hash blocks — into the GLOBAL
109
121
  // ~/.codex/config.toml (codex's security model: trust is global-only). PROJECT trust is UNCONDITIONAL: it
110
122
  // ENABLES the project config layer so codex discovers our hooks at all, a tier bypass_hook_trust does NOT
111
123
  // cover. Claude is a no-op (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim
112
124
  // emitted.
113
- writeTrust(proj: string, cmdFor: (e: string) => string): void
125
+ writeTrust(proj: string, cmdFor: (e: string) => string): readonly string[]
114
126
 
115
127
  // --- the `/` menu ---
116
128
  // the slash-command list, computed the way THIS harness computes its own `/` menu.
@@ -141,6 +153,13 @@ export interface Harness {
141
153
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
142
154
  // Returns ok=false with a reason that propagates to the API.
143
155
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
156
+ // Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
157
+ // without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
158
+ interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
159
+ // Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
160
+ // launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
161
+ // per-session socket. Product teardown calls only this adapter method.
162
+ cleanupRuntime(rec: HarnessLivenessRecord): void
144
163
  // the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
145
164
  // socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
146
165
  // the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
@@ -175,7 +194,13 @@ export interface Harness {
175
194
  // (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
176
195
  // re-exports it for its existing importers.
177
196
  export type DispatchResult = { ok: boolean; error?: string }
178
- export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
197
+ export type HarnessDeliveryRecord = {
198
+ session: string
199
+ worktreePath?: string
200
+ harnessSessionId?: string | null
201
+ runtimeDir?: string
202
+ launchCmd?: string | null
203
+ }
179
204
  // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
180
205
  // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
181
206
  // also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
@@ -421,7 +446,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
421
446
  bypassProbe.set(binary, ok)
422
447
  return ok
423
448
  }
424
- export function codexLaunchCommand(_id: string, codexCmd = 'codex --yolo', serverCmd?: string, dir = runtimeRoot()): string {
449
+ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
425
450
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
426
451
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
427
452
  // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
@@ -756,13 +781,18 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
756
781
  // rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
757
782
  // found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
758
783
  const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
759
- // does a rollout file for this thread id exist yet? Rollouts are grouped by date, so scan only the newest few
760
- // day-dirs (lexical order = chronological on zero-padded YYYY/MM/DD) — clock-agnostic and cheap on a big history.
784
+ // does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
785
+ // (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
786
+ // the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
787
+ // dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
788
+ // every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
789
+ // "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
761
790
  export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
762
791
  const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
763
- const days: string[] = []
764
- 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 }
765
- return days.some((d) => kids(d).some((f) => f.includes(threadId)))
792
+ for (const y of kids(root)) for (const m of kids(join(root, y))) for (const d of kids(join(root, y, m))) {
793
+ if (kids(join(root, y, m, d)).some((f) => f.includes(threadId))) return true
794
+ }
795
+ return false
766
796
  }
767
797
  // poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
768
798
  // caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
@@ -846,11 +876,11 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
846
876
  // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
847
877
  // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
848
878
  // the cli-needing handlers.
849
- function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { json: string; cmd: (e: string) => string } {
879
+ function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { content: string; cmd: (e: string) => string } {
850
880
  const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
851
881
  const hooks: Record<string, unknown> = {}
852
882
  for (const e of events) hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }]
853
- return { json: JSON.stringify({ hooks }, null, 2), cmd }
883
+ return { content: JSON.stringify({ hooks }, null, 2), cmd }
854
884
  }
855
885
 
856
886
  // ---------------------------------------------------------------------------------------------------------
@@ -909,7 +939,7 @@ function stripCodexTrustFor(cur: string, proj: string, hooksJson: string): strin
909
939
  // leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
910
940
  // Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
911
941
  // (`events` may be empty for a trust-only stamp in tests.)
912
- export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): void {
942
+ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): string {
913
943
  const home = process.env.CODEX_HOME || join(homedir(), '.codex')
914
944
  const file = join(home, 'config.toml')
915
945
  const hooksJson = join(proj, '.codex', 'hooks.json')
@@ -922,6 +952,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
922
952
  const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
923
953
  if (!existsSync(home)) mkdirSync(home, { recursive: true })
924
954
  writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
955
+ return file
925
956
  }
926
957
 
927
958
  // the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
@@ -986,7 +1017,9 @@ function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
986
1017
  // (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
987
1018
  // may not yet contain codex — as 'starting', not 'offline'.
988
1019
  const CODEXISH = /^(codex|node)/i // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
989
- export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
1020
+ // the shared descendant-tree walk: does a process matching `re` live BELOW the pane pid? (The pane pid itself
1021
+ // is the shell, so descendants only.) Pure over the caller's one ps snapshot.
1022
+ function paneTreeRuns(pane: PaneProbe | undefined, re: RegExp): boolean {
990
1023
  if (!pane?.panePid || !pane.procs?.size) return false
991
1024
  const kids = new Map<number, number[]>()
992
1025
  for (const [pid, p] of pane.procs) {
@@ -996,27 +1029,80 @@ export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
996
1029
  while (stack.length) {
997
1030
  const pid = stack.pop()!
998
1031
  const comm = pane.procs.get(pid)?.comm ?? ''
999
- if (CODEXISH.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
1032
+ if (re.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
1000
1033
  const c = kids.get(pid); if (c) stack.push(...c)
1001
1034
  }
1002
1035
  return false
1003
1036
  }
1037
+ export function paneTreeRunsCodex(pane?: PaneProbe): boolean { return paneTreeRuns(pane, CODEXISH) }
1038
+
1039
+ // ONE whole-box pid→(ppid, comm) snapshot (a single `ps` spawn) — the table paneTreeRuns walks. Owned here
1040
+ // (beside its consumers) and shared with sessions.ts's liveSnapshot, so the two probe layers can never parse
1041
+ // ps differently. A failed/timed-out ps returns an empty table: the callers read that as not-provably-running.
1042
+ export async function procSnapshot(timeoutMs = 4000): Promise<ProcTable> {
1043
+ const t: ProcTable = new Map()
1044
+ let out = ''
1045
+ try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: timeoutMs, killSignal: 'SIGKILL' })) } catch { return t }
1046
+ for (const line of out.split('\n')) {
1047
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
1048
+ if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
1049
+ }
1050
+ return t
1051
+ }
1004
1052
 
1005
1053
  // ---------------------------------------------------------------------------------------------------------
1006
1054
  // the two implementations.
1007
1055
 
1008
1056
  const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
1009
1057
  const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
1058
+ // the five claude-shaped events pi's generated extension SYNTHESIZES from its own lifecycle (session_start →
1059
+ // SessionStart, input → UserPromptSubmit, tool_call → PreToolUse, tool_result → PostToolUse, agent_end +
1060
+ // agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
1061
+ // real gap as codex.
1062
+ const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
1010
1063
 
1011
1064
  // the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
1012
- // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The bare default is only
1013
- // the fallback for a truly-old record with NO pinned cmd and NO launcher name — there is no env/config-field
1014
- // resolution, because claude/codex are ordinary named launchers now ([[launcher-select]]), resolved by name.
1015
- const claudeBaseCmd = (cmd?: string) => cmd || 'claude --dangerously-skip-permissions'
1016
- const codexBaseCmd = (cmd?: string) => cmd || 'codex --yolo'
1065
+ // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
1066
+ // the fallback for a truly-old record with NO pinned cmd and NO launcher name — compatibility must preserve
1067
+ // the harness's normal permission model, never silently introduce an automatic-permission flag. There is no
1068
+ // env/config-field resolution because launchers are ordinary named config entries ([[launcher-select]]).
1069
+ const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
1070
+ const codexBaseCmd = (cmd?: string) => cmd || 'codex'
1071
+ const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
1072
+ const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
1073
+
1074
+ // @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
1075
+ // opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
1076
+ // prompt arg (a NEW launch → `--prompt`), or a resume marker from opencodeHarness.resumeArg: `--resume <id>`
1077
+ // re-attaches the owned opencode session (`--session <id>`, the SAME conversation), `--continue` re-attaches
1078
+ // the worktree's last session when no id was ever captured (the plugin failed before its first event). A new
1079
+ // launch's tail can never BE a literal marker (it's one quoted prompt), so the branch is unambiguous.
1080
+ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
1081
+ const script = [
1082
+ `if [ "\${1:-}" = "--resume" ]; then`,
1083
+ // the marker carries the owned session id — export it so the plugin can seed rootSession at load: a
1084
+ // resumed session re-fires NO bus event until poked, so without this the rendezvous daemon rejects
1085
+ // every delivery (resume-continuity A-side: continuity ✓, steerability ✗).
1086
+ ` export SPEXCODE_OPENCODE_RESUME_ID="$2"`,
1087
+ ` exec ${opencodeCmd} --session "$2"`,
1088
+ `elif [ "\${1:-}" = "--continue" ]; then`,
1089
+ // no owned id to seed — mark the continue-resume so the plugin knows to ask the SDK for the
1090
+ // reattached session (scoped to this marker so a FRESH launch can never adopt a stale session).
1091
+ ` export SPEXCODE_OPENCODE_CONTINUE=1`,
1092
+ ` exec ${opencodeCmd} --continue`,
1093
+ `elif [ -n "\${1:-}" ]; then`,
1094
+ ` exec ${opencodeCmd} --prompt "$1"`,
1095
+ `else`,
1096
+ ` exec ${opencodeCmd}`,
1097
+ `fi`,
1098
+ ].join('\n')
1099
+ return `bash -lc ${shQuote(script)} spexcode-opencode`
1100
+ }
1017
1101
 
1018
1102
  export const claudeHarness: Harness = {
1019
1103
  id: 'claude',
1104
+ headless: false,
1105
+ messageStream: false,
1020
1106
  events: CLAUDE_EVENTS,
1021
1107
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
1022
1108
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
@@ -1030,7 +1116,7 @@ export const claudeHarness: Harness = {
1030
1116
  skillDir: (proj) => join(proj, '.claude', 'skills'),
1031
1117
  agentDir: (proj) => join(proj, '.claude', 'agents'),
1032
1118
  shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
1033
- writeTrust: () => { /* Claude relies on folder-trust — nothing to write */ },
1119
+ writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
1034
1120
  removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
1035
1121
  clean(proj, arts) { cleanHarness(this, proj, arts) },
1036
1122
  slashCommands: claudeSlashCommands,
@@ -1039,6 +1125,7 @@ export const claudeHarness: Harness = {
1039
1125
  // dead-pane-reads-working bug). See rendezvousListening.
1040
1126
  liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
1041
1127
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1128
+ cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1042
1129
  // the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
1043
1130
  // and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
1044
1131
  // the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
@@ -1051,8 +1138,29 @@ export const claudeHarness: Harness = {
1051
1138
  resumeArg: (rec) => `--resume ${rec.session}`,
1052
1139
  }
1053
1140
 
1141
+ // Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
1142
+ // reused by object composition; the whole runtime half is replaced by the stream-json controller.
1143
+ export const claudeHeadlessHarness: Harness = {
1144
+ ...claudeHarness,
1145
+ id: 'claude-headless',
1146
+ headless: true,
1147
+ messageStream: true,
1148
+ ownsRendezvous: false,
1149
+ paneTitleIsSelfSummary: false,
1150
+ launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
1151
+ // Liveness is the intact record's property. A missing controller/child fails loudly at control time rather
1152
+ // than turning an idle (no child) session into a speculative offline row.
1153
+ liveness: () => 'online',
1154
+ deliver: deliverViaClaudeHeadless,
1155
+ interrupt: interruptClaudeHeadless,
1156
+ cleanupRuntime: (rec) => { try { rmSync(claudeHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ } },
1157
+ deliveryBlockedBy: undefined,
1158
+ }
1159
+
1054
1160
  export const codexHarness: Harness = {
1055
1161
  id: 'codex',
1162
+ headless: false,
1163
+ messageStream: false,
1056
1164
  events: CODEX_EVENTS,
1057
1165
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
1058
1166
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
@@ -1097,7 +1205,7 @@ export const codexHarness: Harness = {
1097
1205
  // the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
1098
1206
  // as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
1099
1207
  // thread still runs the hooks); it does not REPLACE the hashes here.
1100
- writeTrust: (proj, cmdFor) => writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor),
1208
+ writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
1101
1209
  // trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
1102
1210
  removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
1103
1211
  clean(proj, arts) { cleanHarness(this, proj, arts) },
@@ -1115,14 +1223,151 @@ export const codexHarness: Harness = {
1115
1223
  return paneTreeRunsCodex(pane) ? 'online' : 'offline'
1116
1224
  },
1117
1225
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
1226
+ cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
1118
1227
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
1119
1228
  // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
1120
1229
  // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
1121
1230
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
1122
1231
  }
1123
1232
 
1233
+ // @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
1234
+ // the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
1235
+ // lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
1236
+ // hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
1237
+ // extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
1238
+ // dispatch.sh AND binds this session's rendezvous socket itself (sessions.ts already exports
1239
+ // CLAUDE_BG_RENDEZVOUS_SOCK to every ownsRendezvous launch) speaking the reclaude line protocol — so
1240
+ // deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
1241
+ // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
1242
+ // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
1243
+ // one-run defence. See pi-harness.ts for the extension source + trust mechanics.
1244
+ export const piHarness: Harness = {
1245
+ id: 'pi',
1246
+ headless: false,
1247
+ messageStream: false,
1248
+ events: PI_EVENTS,
1249
+ ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
1250
+ paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
1251
+ launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
1252
+ baseCmd: piBaseCmd,
1253
+ sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
1254
+ sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
1255
+ shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
1256
+ worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
1257
+ contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
1258
+ skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
1259
+ agentDir: () => null, // pi has no file-discovered sub-agent primitive — materialize skips it
1260
+ shim: (dispatch, spex) => ({
1261
+ content: piExtensionSource(dispatch, spex),
1262
+ cmd: (e: string) => `SPEX='${spex}' bash ${dispatch} pi ${e}`, // what the extension actually spawns, for parity with buildShim
1263
+ }),
1264
+ writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
1265
+ removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
1266
+ clean(proj, arts) { cleanHarness(this, proj, arts) },
1267
+ slashCommands: piSlashCommands,
1268
+ // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
1269
+ // socket the generated extension binds. socketLive is already probed for every windowed session.
1270
+ liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
1271
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1272
+ cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1273
+ // reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
1274
+ // LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
1275
+ resumeArg: (rec) => `--session ${rec.session}`,
1276
+ }
1277
+
1278
+ // pi-headless is an independent harness: its materialization surface is literally pi's, while a resident
1279
+ // controller owns non-interactive text-mode turns. Active turns steer through pi's rendezvous extension;
1280
+ // idle delivery cold-wakes the exact saved session with `--session` (never `--session-id`, which would create a
1281
+ // new conversation). The controller deliberately reports record-backed liveness, matching Claude headless.
1282
+ export const piHeadlessHarness: Harness = {
1283
+ ...piHarness,
1284
+ id: 'pi-headless',
1285
+ headless: true,
1286
+ messageStream: false,
1287
+ paneTitleIsSelfSummary: false,
1288
+ launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
1289
+ liveness: () => 'online',
1290
+ deliver: deliverViaPiHeadless,
1291
+ cleanupRuntime: (rec) => {
1292
+ try { rmSync(piHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ }
1293
+ try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ }
1294
+ },
1295
+ deliveryBlockedBy: undefined,
1296
+ resumeArg: (rec) => `--session ${rec.session}`,
1297
+ }
1298
+
1299
+ export const opencodeHarness: Harness = {
1300
+ id: 'opencode',
1301
+ headless: false,
1302
+ messageStream: false,
1303
+ events: OPENCODE_EVENTS,
1304
+ // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
1305
+ // socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
1306
+ // parse-confirmed write) and socket-listener liveness are reused verbatim — no opencode transport code.
1307
+ ownsRendezvous: true,
1308
+ paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
1309
+ launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
1310
+ baseCmd: opencodeBaseCmd,
1311
+ sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
1312
+ // opencode exports NO per-session env var to its tool subprocesses (probed, 1.18.3). Identity flows through
1313
+ // the launch-injected SPEXCODE_SESSION_ID — honest here because each opencode TUI is a per-session process
1314
+ // (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
1315
+ // SPEXCODE_SESSION_ID tier resolves the record.
1316
+ sessionEnvVar: 'OPENCODE_SESSION_ID',
1317
+ // the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
1318
+ // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
1319
+ shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
1320
+ worktreeHookAnchor: () => null,
1321
+ contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
1322
+ skillDir: (proj) => join(proj, '.opencode', 'skills'),
1323
+ agentDir: (proj) => join(proj, '.opencode', 'agents'),
1324
+ // content = the plugin source; cmd = the SAME per-event command the plugin bakes into dispatch calls, so
1325
+ // any consumer that hashes/inspects commands sees one truth (trust is a no-op here regardless).
1326
+ shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
1327
+ writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
1328
+ removeTrust: () => { /* nothing was written */ },
1329
+ clean(proj, arts) { cleanHarness(this, proj, arts) },
1330
+ slashCommands: opencodeSlashCommands,
1331
+ // online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
1332
+ // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
1333
+ // load still reads honestly from the process signal instead of a false offline.
1334
+ liveness: (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
1335
+ (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline'),
1336
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1337
+ cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1338
+ // owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
1339
+ // SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
1340
+ // which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
1341
+ // is: a NEW launch's tail is always ONE single-quoted prompt arg, never a literal marker.
1342
+ resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
1343
+ }
1344
+
1345
+ // OpenCode headless is a separate harness, not an opencode mode. Its materialize half is exactly
1346
+ // opencodeHarness; only the one-turn runtime and its capability row differ.
1347
+ export const opencodeHeadlessHarness: Harness = {
1348
+ ...opencodeHarness,
1349
+ id: 'opencode-headless',
1350
+ headless: true,
1351
+ messageStream: false,
1352
+ launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
1353
+ // A sleeping native conversation is still addressable by its record. Transport breakage belongs to the
1354
+ // next delivery, where the live rendezvous or pane wake reports it loudly.
1355
+ liveness: () => 'online',
1356
+ deliver: async (rec, text) => {
1357
+ const probe = await rendezvousListening(rec.session)
1358
+ if (probe === 'live') return deliverViaRendezvous(rec.session, text)
1359
+ if (probe === 'unproven') {
1360
+ return {
1361
+ ok: false,
1362
+ error: `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
1363
+ }
1364
+ }
1365
+ return spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session))
1366
+ },
1367
+ }
1368
+
1124
1369
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
1125
- export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
1370
+ export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness]
1126
1371
 
1127
1372
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
1128
1373
  export const defaultHarness: Harness = claudeHarness
@@ -1135,23 +1380,35 @@ export function harnessById(id: string): Harness {
1135
1380
  }
1136
1381
 
1137
1382
  // --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
1138
- // a launcher = a `{ harness, cmd }` pair in spexcode.json's `sessions.launchers`, keyed by a human-chosen name.
1139
- // `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named launchers (with the
1140
- // regular command path), so they are edited like any other. harness defaults to claude. resolveLauncher throws
1141
- // fail-loud on an unknown name (a session must never silently launch under the wrong auth) and validates the
1142
- // harness id. There is NO env-derived built-in fallback: the dropdown lists exactly the config's real launchers.
1143
- export type Launcher = { name: string; harness: string; cmd: string }
1383
+ // a launcher = a `{ harness, cmd }` entry in spexcode.json's `sessions.launchers`, keyed by a
1384
+ // human-chosen name. `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
1385
+ // launchers (with the regular command path), so they are edited like any other. harness defaults to claude.
1386
+ // resolveLauncher throws fail-loud on an unknown name (a session must never silently launch under the wrong
1387
+ // auth) and validates the harness id. There is NO env-derived built-in fallback: this registry lists exactly
1388
+ // the config's real launchers; dashboardLauncherList applies only the dashboard visibility projection.
1389
+ export type Launcher = { name: string; harness: string; cmd: string; headless: boolean }
1144
1390
  export type LauncherDefault = { default: string | null; error: string | null }
1145
1391
 
1146
- // the configured named launchers from spexcode.json, as a stable name-sorted list (for the dashboard dropdown +
1147
- // the CLI). Picking a launcher is the ONLY launch choice; the old separate harness pick is gone.
1392
+ // the complete configured named launchers from spexcode.json, as a stable name-sorted list (for CLI/session
1393
+ // resolution and downstream projections). Picking a launcher is the ONLY launch choice; the old separate
1394
+ // harness pick is gone.
1148
1395
  export function launcherList(root = mainCheckout()): Launcher[] {
1149
1396
  const m = readConfig(root).sessions?.launchers || {}
1150
1397
  return Object.keys(m)
1151
- .map((name) => ({ name, harness: m[name].harness || defaultHarness.id, cmd: m[name].cmd }))
1398
+ .map((name) => {
1399
+ const harness = harnessById(m[name].harness || defaultHarness.id)
1400
+ return { name, harness: harness.id, cmd: m[name].cmd, headless: harness.headless }
1401
+ })
1152
1402
  .sort((a, b) => a.name.localeCompare(b.name))
1153
1403
  }
1154
1404
 
1405
+ // The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
1406
+ // it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
1407
+ export function dashboardLauncherList(root = mainCheckout()): Launcher[] {
1408
+ const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true
1409
+ return launcherList(root).filter((launcher) => showHeadless || !launcher.headless)
1410
+ }
1411
+
1155
1412
  export const MISSING_DEFAULT_LAUNCHER_ERROR =
1156
1413
  'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
1157
1414
 
@@ -1178,7 +1435,6 @@ export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
1178
1435
  const l = readConfig(root).sessions?.launchers?.[name]
1179
1436
  if (!l) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
1180
1437
  if (!l.cmd) throw new Error(`launcher '${name}' is missing cmd`)
1181
- const resolved = { name, harness: l.harness || defaultHarness.id, cmd: l.cmd }
1182
- harnessById(resolved.harness) // validate the harness id fail-loud
1183
- return resolved
1438
+ const harness = harnessById(l.harness || defaultHarness.id) // validate the harness id fail-loud
1439
+ return { name, harness: harness.id, cmd: l.cmd, headless: harness.headless }
1184
1440
  }