spexcode 0.4.3 → 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 (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/src/claude-headless.ts +271 -0
  4. package/spec-cli/src/cli.ts +24 -1
  5. package/spec-cli/src/client.ts +8 -0
  6. package/spec-cli/src/guide.ts +18 -9
  7. package/spec-cli/src/harness.ts +120 -12
  8. package/spec-cli/src/help.ts +4 -1
  9. package/spec-cli/src/index.ts +19 -7
  10. package/spec-cli/src/layout.ts +1 -0
  11. package/spec-cli/src/message-stream.ts +147 -0
  12. package/spec-cli/src/opencode-headless.ts +95 -0
  13. package/spec-cli/src/pi-headless.ts +195 -0
  14. package/spec-cli/src/sessions.ts +23 -9
  15. package/spec-cli/templates/spexcode.json +7 -1
  16. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  17. package/spec-dashboard/dist/assets/{EvalsPage-DmiX3rdU.js → EvalsPage-5_nfIYll.js} +2 -2
  18. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  19. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  20. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  21. package/spec-dashboard/dist/assets/{PageScroll-C15adEYI.js → PageScroll-B_dKCuXx.js} +1 -1
  22. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  23. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  24. package/spec-dashboard/dist/assets/{SessionWindow-CuDO_67z.js → SessionWindow-BuJ5mzjC.js} +1 -1
  25. package/spec-dashboard/dist/assets/{Settings-C_N1wX1f.js → Settings-B8KFocsz.js} +1 -1
  26. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  27. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  28. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  29. package/spec-dashboard/dist/index.html +2 -2
  30. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +0 -27
  31. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +0 -1
  32. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +0 -2
  33. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +0 -1
  34. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +0 -1
  35. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +0 -39
  36. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +0 -41
  37. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +0 -1
package/README.md CHANGED
@@ -82,7 +82,7 @@ Requires Node ≥ 22 and git. This part is plain tooling — no AI involved yet.
82
82
  ```sh
83
83
  npm i -g spexcode # installs the `spex` command
84
84
  cd your-repo
85
- spex init --harness claude,codex,opencode,pi # seeds .spec/, installs hooks, materializes the agent contracts
85
+ spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/, installs hooks, materializes the agent contracts
86
86
  ```
87
87
 
88
88
  That's the whole adoption. The example lists all the built-in harnesses — remove the ones you don't
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -0,0 +1,271 @@
1
+ import { appendFileSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { createConnection, createServer, type Server, type Socket } from 'node:net'
3
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
9
+
10
+ type ControlRequest = { type: 'deliver'; text: string } | { type: 'interrupt' }
11
+ type ChildTurn = {
12
+ process: ChildProcessWithoutNullStreams
13
+ active: boolean
14
+ exited: Promise<number | null>
15
+ firstEvent: Promise<void>
16
+ sawFirstEvent: () => void
17
+ interruptAcks: Map<string, () => void>
18
+ }
19
+
20
+ const PKG = fileURLToPath(new URL('..', import.meta.url))
21
+ const SPEX = join(PKG, 'bin', 'spex.mjs')
22
+ const CONTROL_TIMEOUT_MS = 30_000
23
+ const START_TIMEOUT_MS = 30_000
24
+ const INTERRUPT_TIMEOUT_MS = 10_000
25
+
26
+ const shQuote = (s: string) => `'${s.replace(/'/g, `'\''`)}'`
27
+ const userEvent = (text: string) => JSON.stringify({
28
+ type: 'user',
29
+ message: { role: 'user', content: [{ type: 'text', text }] },
30
+ })
31
+
32
+ export const claudeHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ch-${id}.sock`)
33
+
34
+ export function claudeHeadlessLaunchCommand(id: string, runtimeDir: string, claudeCmd: string): string {
35
+ return [shQuote(SPEX), 'internal', 'claude-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(claudeCmd), '--'].join(' ')
36
+ }
37
+
38
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
39
+ return new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => reject(new Error(message)), ms)
41
+ promise.then(
42
+ (value) => { clearTimeout(timer); resolve(value) },
43
+ (error) => { clearTimeout(timer); reject(error) },
44
+ )
45
+ })
46
+ }
47
+
48
+ function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
49
+ return new Promise((resolve) => {
50
+ const socket = createConnection(claudeHeadlessSock(id))
51
+ let buffer = ''
52
+ let settled = false
53
+ const finish = (result: DispatchResult) => {
54
+ if (settled) return
55
+ settled = true
56
+ clearTimeout(timer)
57
+ socket.destroy()
58
+ resolve(result)
59
+ }
60
+ const timer = setTimeout(() => finish({ ok: false, error: `claude-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
61
+ socket.setEncoding('utf8')
62
+ socket.on('connect', () => socket.write(`${JSON.stringify(request)}\n`))
63
+ socket.on('data', (chunk) => {
64
+ buffer += chunk
65
+ const nl = buffer.indexOf('\n')
66
+ if (nl < 0) return
67
+ try {
68
+ const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
69
+ finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'claude-headless control rejected the request' })
70
+ } catch (error) {
71
+ finish({ ok: false, error: `claude-headless returned an invalid control response: ${(error as Error).message}` })
72
+ }
73
+ })
74
+ socket.on('error', (error) => finish({ ok: false, error: `claude-headless controller unreachable for session ${id}: ${error.message}` }))
75
+ socket.on('close', () => finish({ ok: false, error: `claude-headless controller closed before confirming session ${id}` }))
76
+ })
77
+ }
78
+
79
+ export const deliverViaClaudeHeadless = (rec: HarnessDeliveryRecord, text: string) =>
80
+ controlRequest(rec.session, { type: 'deliver', text })
81
+
82
+ export const interruptClaudeHeadless = (rec: HarnessDeliveryRecord) =>
83
+ controlRequest(rec.session, { type: 'interrupt' })
84
+
85
+ export class ClaudeHeadlessController {
86
+ private server: Server | null = null
87
+ private child: ChildTurn | null = null
88
+ private controlQueue: Promise<void> = Promise.resolve()
89
+ private closing = false
90
+ private readonly messagesPath: string
91
+ private readonly socketPath: string
92
+
93
+ constructor(
94
+ private readonly id: string,
95
+ runtimeDir: string,
96
+ private readonly claudeCmd: string,
97
+ private readonly cwd = process.cwd(),
98
+ ) {
99
+ const dir = join(runtimeDir, 'sessions', id)
100
+ mkdirSync(dir, { recursive: true })
101
+ this.messagesPath = join(dir, 'messages.ndjson')
102
+ this.socketPath = claudeHeadlessSock(id)
103
+ }
104
+
105
+ async start(initialPrompt?: string): Promise<void> {
106
+ try { rmSync(this.socketPath, { force: true }) } catch { /* stale control socket is replaced at startup */ }
107
+ this.server = createServer((socket) => this.accept(socket))
108
+ await new Promise<void>((resolve, reject) => {
109
+ const onError = (error: Error) => { this.server?.off('listening', onListening); reject(error) }
110
+ const onListening = () => { this.server?.off('error', onError); resolve() }
111
+ this.server!.once('error', onError)
112
+ this.server!.once('listening', onListening)
113
+ this.server!.listen(this.socketPath)
114
+ })
115
+ if (initialPrompt) void this.spawnTurn(initialPrompt, false).catch((error) => {
116
+ console.error(`[spex claude-headless] initial turn failed: ${(error as Error).message}`)
117
+ })
118
+ }
119
+
120
+ async close(): Promise<void> {
121
+ if (this.closing) return
122
+ this.closing = true
123
+ const child = this.child
124
+ if (child && child.process.exitCode === null) child.process.kill('SIGTERM')
125
+ await new Promise<void>((resolve) => {
126
+ if (!this.server) return resolve()
127
+ this.server.close(() => resolve())
128
+ })
129
+ try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
130
+ }
131
+
132
+ private accept(socket: Socket): void {
133
+ socket.setEncoding('utf8')
134
+ let buffer = ''
135
+ let handled = false
136
+ socket.on('data', (chunk) => {
137
+ if (handled) return
138
+ buffer += chunk
139
+ const nl = buffer.indexOf('\n')
140
+ if (nl < 0) return
141
+ handled = true
142
+ let request: ControlRequest
143
+ try {
144
+ request = JSON.parse(buffer.slice(0, nl)) as ControlRequest
145
+ } catch (error) {
146
+ socket.end(`${JSON.stringify({ ok: false, error: `invalid control request: ${(error as Error).message}` })}\n`)
147
+ return
148
+ }
149
+ this.controlQueue = this.controlQueue.then(async () => {
150
+ const result = await this.handle(request).catch((error) => ({ ok: false, error: (error as Error).message }))
151
+ socket.end(`${JSON.stringify(result)}\n`)
152
+ })
153
+ })
154
+ }
155
+
156
+ private async handle(request: ControlRequest): Promise<DispatchResult> {
157
+ if (request.type === 'deliver') {
158
+ if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
159
+ const current = this.child
160
+ if (current?.active && current.process.stdin.writable) {
161
+ await this.writeLine(current, userEvent(request.text))
162
+ return { ok: true }
163
+ }
164
+ if (current) await withTimeout(current.exited, 5_000, 'previous claude-headless turn did not exit after its result')
165
+ await this.spawnTurn(request.text, true)
166
+ return { ok: true }
167
+ }
168
+ if (request.type === 'interrupt') return this.interrupt()
169
+ return { ok: false, error: 'unknown claude-headless control request' }
170
+ }
171
+
172
+ private async interrupt(): Promise<DispatchResult> {
173
+ const child = this.child
174
+ if (!child?.active || !child.process.stdin.writable) return { ok: true }
175
+ const requestId = randomUUID()
176
+ const ack = new Promise<void>((resolve) => child.interruptAcks.set(requestId, resolve))
177
+ await this.writeLine(child, JSON.stringify({
178
+ type: 'control_request',
179
+ request_id: requestId,
180
+ request: { subtype: 'interrupt' },
181
+ }))
182
+ try {
183
+ await withTimeout(ack, INTERRUPT_TIMEOUT_MS, `claude-headless interrupt was not confirmed for session ${this.id}`)
184
+ return { ok: true }
185
+ } finally {
186
+ child.interruptAcks.delete(requestId)
187
+ }
188
+ }
189
+
190
+ private async spawnTurn(text: string, resume: boolean): Promise<void> {
191
+ if (this.closing) throw new Error('claude-headless controller is closing')
192
+ const mode = resume ? ['--resume', this.id] : ['--session-id', this.id]
193
+ const args = ['-p', ...mode, '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']
194
+ const command = `exec ${this.claudeCmd} ${args.map(shQuote).join(' ')}`
195
+ const childProcess = spawn('/bin/sh', ['-lc', command], { cwd: this.cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] })
196
+ let sawFirstEvent!: () => void
197
+ const firstEvent = new Promise<void>((resolve) => { sawFirstEvent = resolve })
198
+ let resolveExit!: (code: number | null) => void
199
+ const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
200
+ const turn: ChildTurn = { process: childProcess, active: true, exited, firstEvent, sawFirstEvent, interruptAcks: new Map() }
201
+ this.child = turn
202
+ let stdoutBuffer = ''
203
+ childProcess.stdout.setEncoding('utf8')
204
+ childProcess.stderr.pipe(process.stderr)
205
+ childProcess.stdout.on('data', (chunk) => {
206
+ stdoutBuffer += chunk
207
+ for (;;) {
208
+ const nl = stdoutBuffer.indexOf('\n')
209
+ if (nl < 0) break
210
+ const line = stdoutBuffer.slice(0, nl)
211
+ stdoutBuffer = stdoutBuffer.slice(nl + 1)
212
+ const nativeLine = `${line}\n`
213
+ appendFileSync(this.messagesPath, nativeLine)
214
+ globalThis.process.stdout.write(nativeLine)
215
+ turn.sawFirstEvent()
216
+ this.observeEvent(turn, line)
217
+ }
218
+ })
219
+ childProcess.once('error', (error) => {
220
+ console.error(`[spex claude-headless] child spawn failed: ${error.message}`)
221
+ })
222
+ childProcess.once('close', (code) => {
223
+ turn.active = false
224
+ if (stdoutBuffer) console.error('[spex claude-headless] dropped a partial non-line stdout event')
225
+ if (this.child === turn) this.child = null
226
+ resolveExit(code)
227
+ })
228
+ await this.writeLine(turn, userEvent(text))
229
+ await Promise.race([
230
+ withTimeout(turn.firstEvent, START_TIMEOUT_MS, `claude-headless child produced no stream event for session ${this.id}`),
231
+ turn.exited.then((code) => { throw new Error(`claude-headless child exited before accepting the turn (code ${code ?? 'signal'})`) }),
232
+ ])
233
+ }
234
+
235
+ private observeEvent(turn: ChildTurn, line: string): void {
236
+ let event: any
237
+ try { event = JSON.parse(line) } catch { return }
238
+ if (event?.type === 'control_response' && typeof event?.response?.request_id === 'string') {
239
+ turn.interruptAcks.get(event.response.request_id)?.()
240
+ }
241
+ if (event?.type === 'result') {
242
+ turn.active = false
243
+ turn.process.stdin.end()
244
+ }
245
+ }
246
+
247
+ private writeLine(turn: ChildTurn, line: string): Promise<void> {
248
+ return new Promise((resolve, reject) => {
249
+ if (!turn.process.stdin.writable) return reject(new Error('claude-headless child stdin is not writable'))
250
+ turn.process.stdin.write(`${line}\n`, (error) => error ? reject(error) : resolve())
251
+ })
252
+ }
253
+ }
254
+
255
+ export async function runClaudeHeadlessController(
256
+ id: string,
257
+ runtimeDir: string,
258
+ claudeCmd: string,
259
+ tail: string[],
260
+ ): Promise<void> {
261
+ const controller = new ClaudeHeadlessController(id, runtimeDir, claudeCmd)
262
+ const resume = tail[0] === '--resume'
263
+ const prompt = resume ? undefined : tail[0] === '--session-id' ? tail.slice(2).join(' ') : tail.join(' ')
264
+ await controller.start(prompt)
265
+ await new Promise<void>((resolve) => {
266
+ const stop = () => void controller.close().finally(resolve)
267
+ process.once('SIGINT', stop)
268
+ process.once('SIGTERM', stop)
269
+ process.once('SIGHUP', stop)
270
+ })
271
+ }
@@ -786,6 +786,11 @@ if (cmd === 'serve') {
786
786
  // can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
787
787
  const full = await resolveSelectorOrExit(id)
788
788
  console.log(await c.clientStop(full) ? `stopped ${full} (worktree kept — resumable)` : `no such session ${full}`)
789
+ } else if (sub === 'interrupt') {
790
+ const full = await resolveSelectorOrExit(id)
791
+ const r = await c.clientInterrupt(full)
792
+ console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`)
793
+ process.exit(r.ok ? 0 : 1)
789
794
  } else if (sub === 'close') {
790
795
  const full = await resolveSelectorOrExit(id)
791
796
  console.log(await c.clientClose(full) ? `closed ${full}` : `no such session ${full}`)
@@ -868,7 +873,7 @@ if (cmd === 'serve') {
868
873
  await assertLocalBackend()
869
874
  await attachSession(await resolveSelectorOrExit(id))
870
875
  } else {
871
- console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
876
+ console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
872
877
  process.exit(2)
873
878
  }
874
879
  }
@@ -938,6 +943,24 @@ if (cmd === 'serve') {
938
943
  if (!ocid) { console.error('usage: spex internal opencode-capture <opencode-session-id>'); process.exit(2) }
939
944
  const sid = process.env.SPEXCODE_SESSION_ID
940
945
  console.log(sid && markHarnessSessionId(sid, ocid) ? `captured ${ocid}` : 'noop (no governed session record)')
946
+ } else if (sub === 'claude-headless-run') {
947
+ const id = process.argv[4], runtimeDir = process.argv[5], claudeCmd = process.argv[6]
948
+ const divider = process.argv[7]
949
+ if (!id || !runtimeDir || !claudeCmd || divider !== '--') {
950
+ console.error('usage: spex internal claude-headless-run <session-id> <runtime-dir> <claude-cmd> -- [--session-id <id> <prompt> | --resume <id>]')
951
+ process.exit(2)
952
+ }
953
+ const { runClaudeHeadlessController } = await import('./claude-headless.js')
954
+ await runClaudeHeadlessController(id, runtimeDir, claudeCmd, process.argv.slice(8))
955
+ } else if (sub === 'pi-headless-run') {
956
+ const id = process.argv[4], runtimeDir = process.argv[5], piCmd = process.argv[6]
957
+ const divider = process.argv[7]
958
+ if (!id || !runtimeDir || !piCmd || divider !== '--') {
959
+ console.error('usage: spex internal pi-headless-run <session-id> <runtime-dir> <pi-cmd> -- [--session-id <id> <prompt> | --session <id>]')
960
+ process.exit(2)
961
+ }
962
+ const { runPiHeadlessController } = await import('./pi-headless.js')
963
+ await runPiHeadlessController(id, runtimeDir, piCmd, process.argv.slice(8))
941
964
  } else if (sub === 'commit-surgery') {
942
965
  // the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
943
966
  // (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
@@ -132,6 +132,14 @@ export async function clientStop(id: string): Promise<boolean> {
132
132
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
133
133
  }
134
134
 
135
+ // POST /api/sessions/:id/interrupt - native hard interrupt of the current turn. Unsupported harnesses and
136
+ // unreachable control planes return the backend's loud DispatchResult; no signal/raw-key fallback exists.
137
+ export async function clientInterrupt(id: string): Promise<DispatchResult> {
138
+ await guarded('session interrupt')
139
+ const r = await apiFetch(`/api/sessions/${seg(id)}/interrupt`, post({}))
140
+ return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
141
+ }
142
+
135
143
  // POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
136
144
  export async function clientClose(id: string): Promise<boolean> {
137
145
  await guarded('session close')
@@ -14,7 +14,7 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
14
14
  startup commands.)
15
15
 
16
16
  2. Adopt a repo
17
- cd <your-repo> && spex init --harness claude,codex,opencode,pi # seeds .spec/ + git hooks (additive, never overwrites)
17
+ cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/ + git hooks (additive, never overwrites)
18
18
  --harness is required and has no default — the explicit choice of which harness(es) materialize
19
19
  delivers into. The example lists every built-in; drop the ones you don't use (any one id or
20
20
  comma-separated subset is valid).
@@ -259,14 +259,14 @@ settings verb — an agent CONFIGURES SpexCode by EDITING these files directly.
259
259
  PORTABILITY, and picking the right one is the whole discipline:
260
260
 
261
261
  spexcode.json COMMITTED — portable, shared by everyone on the repo. Layout, policy, dashboard
262
- identity, lint policy, doctor health budgets, launcher NAMES. "Git is the database": tracked so the
262
+ identity and launcher visibility, lint policy, doctor health budgets, launcher NAMES. "Git is the database": tracked so the
263
263
  team shares ONE configuration.
264
264
  spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
265
265
  paths. Layered OVER spexcode.json (see MERGE
266
266
  below); a targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
267
267
 
268
268
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
269
- icon, lint policy, doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
269
+ icon or launcher-visibility policy, lint policy, doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
270
270
  PATH of a launcher wrapper or a TLS cert path are machine facts → gitignored spexcode.local.json.
271
271
  Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
272
272
  \`spex session new\` or the dashboard without an explicit launcher choice.
@@ -296,8 +296,13 @@ Example — a repo whose trunk is \`staging\`, not \`main\`:
296
296
  dashboard.apiUrl the per-project backend the dashboard proxies to (read frontend-side). For a SHARED
297
297
  install prefer the API_URL env var; apiUrl here is the default only when the dashboard
298
298
  lives inside the project.
299
+ dashboard.showHeadlessLaunchers
300
+ include launchers whose harness declares itself headless in the dashboard New Session
301
+ picker. Default: false. This changes dashboard visibility only; explicit CLI
302
+ --launcher selection can still use every configured launcher.
299
303
  Example:
300
- { "dashboard": { "title": "MyApp specs", "icon": "mdi:rocket-launch" } }
304
+ { "dashboard": { "title": "MyApp specs", "icon": "mdi:rocket-launch",
305
+ "showHeadlessLaunchers": false } }
301
306
 
302
307
  ── HOST GATEWAY ($SPEXCODE_HOME/config.json — per-user host identity, never a project file) ──
303
308
  gateway.icon the global /projects icon, using the same preset ids above. Default: "gateway".
@@ -326,16 +331,20 @@ by name with --launcher/the dashboard dropdown, and the chosen name is persisted
326
331
  reuses the same auth. There are NO magic built-ins: \`spex init\` SEEDS an ordinary named launcher for each
327
332
  harness the adopter SELECTED (--harness), from the template pool
328
333
  "claude" → { "harness": "claude", "cmd": "claude" }
334
+ "claude-headless" → { "harness": "claude-headless", "cmd": "claude" }
329
335
  "codex" → { "harness": "codex", "cmd": "codex" }
330
336
  "opencode" → { "harness": "opencode", "cmd": "opencode" }
337
+ "opencode-headless" → { "harness": "opencode-headless", "cmd": "opencode --auto" }
331
338
  "pi" → { "harness": "pi", "cmd": "pi" }
332
- These plain commands preserve each harness's normal permission model. Automatic-permission commands such as
333
- \`claude --dangerously-skip-permissions\`, \`codex --yolo\`, or \`opencode --auto\` are NEVER clean-init
334
- defaults: define and select one explicitly only when that access is intended. To run workers under an auth
339
+ "pi-headless" { "harness": "pi-headless", "cmd": "pi" }
340
+ The interactive profiles preserve each harness's normal permission model. \`opencode-headless\` is the one
341
+ deliberate seed exception: its terminal-free run requires \`opencode --auto\`; interactive \`opencode\` stays
342
+ plain. Other automatic-permission commands are NEVER clean-init defaults: define and select one explicitly
343
+ only when that access is intended. To run workers under an auth
335
344
  wrapper (e.g. reclaude), point a launcher's \`cmd\` at it in spexcode.local.json — there is no environment
336
345
  override that rewrites a launcher's command. Add more profiles when a project needs named auth/config-dir or
337
346
  permission variants. Shape:
338
- "launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi",
347
+ "launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "opencode-headless" | "pi-headless",
339
348
  "cmd": "<launch command>" } }
340
349
  \`harness\` defaults to "claude"; \`cmd\` is required and embedded whole. A portable plain command may live
341
350
  in committed spexcode.json (as the init seeds do). A host-specific command — an absolute wrapper path,
@@ -444,7 +453,7 @@ Example — tune opt-in health diagnosis without changing the lint gate:
444
453
  preset the SELECTED init preset — which cumulative .plugins tier \`spex init\` seeds (default
445
454
  'default'; seed-time only, read by init.ts).
446
455
  harnesses which harness targets \`spex materialize\` delivers into — native ids
447
- ("claude"|"codex"|"opencode"|"pi") or a
456
+ ("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"opencode-headless"|"pi-headless") or a
448
457
  { "plugin": "<folder>" } bundle. REQUIRED — there is no default set: \`spex init --harness\`
449
458
  stamps the explicit choice, and a missing field fails materialize loud. PERSISTENT and
450
459
  git-transactional: the edit takes effect at the next git-native materialize anchor (the commit