spexcode 0.5.0 → 0.5.2

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 (32) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/spec-cli/src/claude-headless.ts +105 -16
  4. package/spec-cli/src/cli.ts +13 -2
  5. package/spec-cli/src/codex-headless.ts +13 -0
  6. package/spec-cli/src/guide.ts +8 -7
  7. package/spec-cli/src/harness.ts +53 -5
  8. package/spec-cli/src/host.ts +1 -0
  9. package/spec-cli/src/opencode-headless.ts +119 -5
  10. package/spec-cli/src/opencode.ts +8 -4
  11. package/spec-cli/src/pi-headless.ts +1 -0
  12. package/spec-cli/src/pty-bridge.ts +31 -4
  13. package/spec-cli/src/pty-helper.mjs +16 -6
  14. package/spec-cli/src/pty-native-helper.mjs +22 -0
  15. package/spec-cli/src/session-timeline.ts +29 -21
  16. package/spec-cli/src/sessions.ts +72 -32
  17. package/spec-cli/templates/spexcode.json +1 -0
  18. package/spec-dashboard/dist/assets/{Dashboard-C_w_wdk5.js → Dashboard-CTAuTyZ3.js} +3 -3
  19. package/spec-dashboard/dist/assets/{EvalsPage-5_nfIYll.js → EvalsPage-KbMMownG.js} +2 -2
  20. package/spec-dashboard/dist/assets/{IssuesPage-By-u--95.js → IssuesPage-DmyLb9Rj.js} +1 -1
  21. package/spec-dashboard/dist/assets/{MobileApp-CVEwjHr9.js → MobileApp-D2RZGt4Z.js} +2 -2
  22. package/spec-dashboard/dist/assets/{Modal-BqgvzMJD.js → Modal-3brXUhM0.js} +1 -1
  23. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CadAKuSy.js} +1 -1
  24. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +1 -0
  25. package/spec-dashboard/dist/assets/{SessionInterface-Bh3vq8SU.js → SessionInterface-BtrzlOPs.js} +1 -1
  26. package/spec-dashboard/dist/assets/{SessionWindow-BuJ5mzjC.js → SessionWindow-BWH5O0jh.js} +1 -1
  27. package/spec-dashboard/dist/assets/{Settings-B8KFocsz.js → Settings-COgdKTJB.js} +1 -1
  28. package/spec-dashboard/dist/assets/{TimelineChat-K0wdlweB.js → TimelineChat-DQ21GSJK.js} +1 -1
  29. package/spec-dashboard/dist/assets/{index-BKaTHjmU.js → index-D6HBvKkJ.js} +2 -2
  30. package/spec-dashboard/dist/assets/{index-DcnCaBAC.css → index-DFdlYy4H.css} +1 -1
  31. package/spec-dashboard/dist/index.html +2 -2
  32. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +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,claude-headless,opencode-headless,pi-headless # seeds .spec/, installs hooks, materializes the agent contracts
85
+ spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-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.5.0",
3
+ "version": "0.5.2",
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",
@@ -39,7 +39,7 @@
39
39
  "@hono/node-server": "^1.13.7",
40
40
  "@hono/node-ws": "^1.0.4",
41
41
  "hono": "^4.6.14",
42
- "node-pty": "^1.1.0",
42
+ "node-pty": "1.2.0-beta.14",
43
43
  "tsx": "^4.19.2"
44
44
  },
45
45
  "devDependencies": {
@@ -7,11 +7,16 @@ import { join } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
9
9
 
10
- type ControlRequest = { type: 'deliver'; text: string } | { type: 'interrupt' }
10
+ type ControlRequest = { type: 'deliver'; text: string; mode: 'steer' | 'wake' } | { type: 'interrupt' }
11
+ type ClaudeHeadlessDeliveryRecord = HarnessDeliveryRecord & { status?: string }
11
12
  type ChildTurn = {
12
13
  process: ChildProcessWithoutNullStreams
13
14
  active: boolean
15
+ completed: boolean
14
16
  exited: Promise<number | null>
17
+ teardown: Promise<void> | null
18
+ result: Promise<void>
19
+ sawResult: () => void
15
20
  firstEvent: Promise<void>
16
21
  sawFirstEvent: () => void
17
22
  interruptAcks: Map<string, () => void>
@@ -19,9 +24,13 @@ type ChildTurn = {
19
24
 
20
25
  const PKG = fileURLToPath(new URL('..', import.meta.url))
21
26
  const SPEX = join(PKG, 'bin', 'spex.mjs')
22
- const CONTROL_TIMEOUT_MS = 30_000
27
+ const CONTROL_TIMEOUT_MS = 60_000
23
28
  const START_TIMEOUT_MS = 30_000
24
29
  const INTERRUPT_TIMEOUT_MS = 10_000
30
+ const RESULT_WAIT_MS = 20_000
31
+ const RESULT_EXIT_GRACE_MS = 500
32
+ const TERM_EXIT_GRACE_MS = 500
33
+ const KILL_EXIT_GRACE_MS = 2_000
25
34
 
26
35
  const shQuote = (s: string) => `'${s.replace(/'/g, `'\''`)}'`
27
36
  const userEvent = (text: string) => JSON.stringify({
@@ -76,8 +85,8 @@ function controlRequest(id: string, request: ControlRequest): Promise<DispatchRe
76
85
  })
77
86
  }
78
87
 
79
- export const deliverViaClaudeHeadless = (rec: HarnessDeliveryRecord, text: string) =>
80
- controlRequest(rec.session, { type: 'deliver', text })
88
+ export const deliverViaClaudeHeadless = (rec: ClaudeHeadlessDeliveryRecord, text: string) =>
89
+ controlRequest(rec.session, { type: 'deliver', text, mode: rec.status === 'active' ? 'steer' : 'wake' })
81
90
 
82
91
  export const interruptClaudeHeadless = (rec: HarnessDeliveryRecord) =>
83
92
  controlRequest(rec.session, { type: 'interrupt' })
@@ -121,12 +130,15 @@ export class ClaudeHeadlessController {
121
130
  if (this.closing) return
122
131
  this.closing = true
123
132
  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 */ }
133
+ try {
134
+ if (child) await this.ensureTurnExit(child)
135
+ } finally {
136
+ await new Promise<void>((resolve) => {
137
+ if (!this.server) return resolve()
138
+ this.server.close(() => resolve())
139
+ })
140
+ try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
141
+ }
130
142
  }
131
143
 
132
144
  private accept(socket: Socket): void {
@@ -157,11 +169,17 @@ export class ClaudeHeadlessController {
157
169
  if (request.type === 'deliver') {
158
170
  if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
159
171
  const current = this.child
160
- if (current?.active && current.process.stdin.writable) {
172
+ if (request.mode === 'steer' && current?.active && current.process.stdin.writable) {
161
173
  await this.writeLine(current, userEvent(request.text))
162
174
  return { ok: true }
163
175
  }
164
- if (current) await withTimeout(current.exited, 5_000, 'previous claude-headless turn did not exit after its result')
176
+ if (current?.active) {
177
+ await Promise.race([
178
+ withTimeout(current.result, RESULT_WAIT_MS, 'previous claude-headless turn did not reach its result before idle wake'),
179
+ current.exited.then((code) => { throw new Error(`previous claude-headless turn exited before its result (code ${code ?? 'signal'})`) }),
180
+ ])
181
+ }
182
+ if (current) await this.ensureTurnExit(current)
165
183
  await this.spawnTurn(request.text, true)
166
184
  return { ok: true }
167
185
  }
@@ -192,12 +210,30 @@ export class ClaudeHeadlessController {
192
210
  const mode = resume ? ['--resume', this.id] : ['--session-id', this.id]
193
211
  const args = ['-p', ...mode, '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']
194
212
  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'] })
213
+ const childProcess = spawn('/bin/sh', ['-lc', command], {
214
+ cwd: this.cwd,
215
+ env: process.env,
216
+ detached: true,
217
+ stdio: ['pipe', 'pipe', 'pipe'],
218
+ })
196
219
  let sawFirstEvent!: () => void
197
220
  const firstEvent = new Promise<void>((resolve) => { sawFirstEvent = resolve })
198
221
  let resolveExit!: (code: number | null) => void
199
222
  const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
200
- const turn: ChildTurn = { process: childProcess, active: true, exited, firstEvent, sawFirstEvent, interruptAcks: new Map() }
223
+ let sawResult!: () => void
224
+ const result = new Promise<void>((resolve) => { sawResult = resolve })
225
+ const turn: ChildTurn = {
226
+ process: childProcess,
227
+ active: true,
228
+ completed: false,
229
+ exited,
230
+ teardown: null,
231
+ result,
232
+ sawResult,
233
+ firstEvent,
234
+ sawFirstEvent,
235
+ interruptAcks: new Map(),
236
+ }
201
237
  this.child = turn
202
238
  let stdoutBuffer = ''
203
239
  childProcess.stdout.setEncoding('utf8')
@@ -224,6 +260,9 @@ export class ClaudeHeadlessController {
224
260
  if (stdoutBuffer) console.error('[spex claude-headless] dropped a partial non-line stdout event')
225
261
  if (this.child === turn) this.child = null
226
262
  resolveExit(code)
263
+ if (!turn.completed && code !== 0 && !this.closing) {
264
+ void import('./harness.js').then(({ reportHeadlessTurnExit }) => reportHeadlessTurnExit(this.id, 'claude-headless', code, this.cwd))
265
+ }
227
266
  })
228
267
  await this.writeLine(turn, userEvent(text))
229
268
  await Promise.race([
@@ -238,9 +277,59 @@ export class ClaudeHeadlessController {
238
277
  if (event?.type === 'control_response' && typeof event?.response?.request_id === 'string') {
239
278
  turn.interruptAcks.get(event.response.request_id)?.()
240
279
  }
241
- if (event?.type === 'result') {
280
+ if (event?.type === 'result' && !turn.completed) {
281
+ turn.completed = true
242
282
  turn.active = false
243
- turn.process.stdin.end()
283
+ turn.sawResult()
284
+ void this.ensureTurnExit(turn).catch((error) => {
285
+ console.error(`[spex claude-headless] completed turn teardown failed: ${(error as Error).message}`)
286
+ })
287
+ }
288
+ }
289
+
290
+ private ensureTurnExit(turn: ChildTurn): Promise<void> {
291
+ turn.teardown ??= this.terminateTurn(turn)
292
+ return turn.teardown
293
+ }
294
+
295
+ private async terminateTurn(turn: ChildTurn): Promise<void> {
296
+ if (turn.completed) {
297
+ if (turn.process.stdin.writable) turn.process.stdin.end()
298
+ if (await this.waitForTurnExit(turn, RESULT_EXIT_GRACE_MS)) return
299
+ this.signalTurn(turn, 'SIGKILL')
300
+ await withTimeout(
301
+ turn.exited,
302
+ KILL_EXIT_GRACE_MS,
303
+ `claude-headless completed turn process group ${turn.process.pid ?? 'unknown'} did not exit after SIGKILL`,
304
+ )
305
+ return
306
+ }
307
+ this.signalTurn(turn, 'SIGTERM')
308
+ if (await this.waitForTurnExit(turn, TERM_EXIT_GRACE_MS)) return
309
+ this.signalTurn(turn, 'SIGKILL')
310
+ await withTimeout(
311
+ turn.exited,
312
+ KILL_EXIT_GRACE_MS,
313
+ `claude-headless turn process group ${turn.process.pid ?? 'unknown'} did not exit after SIGKILL`,
314
+ )
315
+ }
316
+
317
+ private async waitForTurnExit(turn: ChildTurn, timeoutMs: number): Promise<boolean> {
318
+ try {
319
+ await withTimeout(turn.exited, timeoutMs, 'turn exit grace elapsed')
320
+ return true
321
+ } catch {
322
+ return false
323
+ }
324
+ }
325
+
326
+ private signalTurn(turn: ChildTurn, signal: NodeJS.Signals): void {
327
+ const pid = turn.process.pid
328
+ try {
329
+ if (pid) process.kill(-pid, signal)
330
+ else turn.process.kill(signal)
331
+ } catch (error) {
332
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
244
333
  }
245
334
  }
246
335
 
@@ -216,8 +216,9 @@ async function evalExport(id: string): Promise<never> {
216
216
  process.exit(0)
217
217
  }
218
218
 
219
- // appended to a done/ask/block declaration: states (not commands) that the next tool call's mark-active hook re-flips the global record to active, so a re-read won't show this.
220
- const DECLARED = ' recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
219
+ // appended to a done/ask/block declaration: the note is durable conversation history even though the
220
+ // CURRENT board projection correctly flips back to active on the next tool call.
221
+ const DECLARED = ' — recorded; the human sees it in the dashboard. This declaration remains in the session timeline; your next tool call flips only the current board state back to active (the mark-active hook, by design).'
221
222
  // appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
222
223
  const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
223
224
 
@@ -998,6 +999,16 @@ if (cmd === 'serve') {
998
999
  // the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
999
1000
  const { s, sess, mark, noRecord } = await stateKit()
1000
1001
  console.log(mark(() => s.markError(sess)) ? 'marked error' : noRecord())
1002
+ } else if (sub === 'session-turn-fail') {
1003
+ // Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
1004
+ // that landed before teardown wins, so a late child close can never erase an agent-authored state.
1005
+ const sessionId = process.argv[4], harness = process.argv[5], exitCode = process.argv[6]
1006
+ if (!sessionId || !harness || !exitCode) {
1007
+ console.error('usage: spex internal session-turn-fail <session-id> <harness> <exit-code|signal>')
1008
+ process.exit(2)
1009
+ }
1010
+ const { markHeadlessTurnFailure } = await import('./sessions.js')
1011
+ console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (no active session record)')
1001
1012
  } else if (sub === 'session-idle') {
1002
1013
  // the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
1003
1014
  // at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
@@ -0,0 +1,13 @@
1
+ import { codexLaunchCommand } from './harness.js'
2
+
3
+ // Codex headless keeps the existing app-server bootstrap and backend-owned thread/start + first turn, but
4
+ // deliberately omits the final interactive `--remote … resume` attach. The shared app-server remains the
5
+ // durable home for the thread; follow-up delivery uses codexHarness' existing JSON-RPC transport.
6
+ export function codexHeadlessLaunchCommand(
7
+ id: string,
8
+ codexCmd = 'codex',
9
+ serverCmd?: string,
10
+ dir?: string,
11
+ ): string {
12
+ return codexLaunchCommand(id, codexCmd, serverCmd, dir, false)
13
+ }
@@ -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,claude-headless,opencode-headless,pi-headless # seeds .spec/ + git hooks (additive, never overwrites)
17
+ cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-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).
@@ -333,18 +333,19 @@ harness the adopter SELECTED (--harness), from the template pool
333
333
  "claude" → { "harness": "claude", "cmd": "claude" }
334
334
  "claude-headless" → { "harness": "claude-headless", "cmd": "claude" }
335
335
  "codex" → { "harness": "codex", "cmd": "codex" }
336
+ "codex-headless" → { "harness": "codex-headless", "cmd": "codex --yolo" }
336
337
  "opencode" → { "harness": "opencode", "cmd": "opencode" }
337
338
  "opencode-headless" → { "harness": "opencode-headless", "cmd": "opencode --auto" }
338
339
  "pi" → { "harness": "pi", "cmd": "pi" }
339
340
  "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
341
+ The interactive profiles preserve each harness's normal permission model. \`opencode-headless\` and
342
+ \`codex-headless\` are deliberate seed exceptions: their terminal-free runs require \`opencode --auto\` and
343
+ \`codex --yolo\`; interactive profiles stay plain. Other automatic-permission commands are NEVER clean-init
344
+ defaults: define and select one explicitly only when that access is intended. To run workers under an auth
344
345
  wrapper (e.g. reclaude), point a launcher's \`cmd\` at it in spexcode.local.json — there is no environment
345
346
  override that rewrites a launcher's command. Add more profiles when a project needs named auth/config-dir or
346
347
  permission variants. Shape:
347
- "launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "opencode-headless" | "pi-headless",
348
+ "launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "codex-headless" | "opencode-headless" | "pi-headless",
348
349
  "cmd": "<launch command>" } }
349
350
  \`harness\` defaults to "claude"; \`cmd\` is required and embedded whole. A portable plain command may live
350
351
  in committed spexcode.json (as the init seeds do). A host-specific command — an absolute wrapper path,
@@ -453,7 +454,7 @@ Example — tune opt-in health diagnosis without changing the lint gate:
453
454
  preset the SELECTED init preset — which cumulative .plugins tier \`spex init\` seeds (default
454
455
  'default'; seed-time only, read by init.ts).
455
456
  harnesses which harness targets \`spex materialize\` delivers into — native ids
456
- ("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"opencode-headless"|"pi-headless") or a
457
+ ("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"codex-headless"|"opencode-headless"|"pi-headless") or a
457
458
  { "plugin": "<folder>" } bundle. REQUIRED — there is no default set: \`spex init --harness\`
458
459
  stamps the explicit choice, and a missing field fails materialize loud. PERSISTENT and
459
460
  git-transactional: the edit takes effect at the next git-native materialize anchor (the commit
@@ -10,6 +10,7 @@ import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlash
10
10
  import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
11
11
  import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
12
12
  import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js'
13
+ import { codexHeadlessLaunchCommand } from './codex-headless.js'
13
14
  import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
14
15
  import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
15
16
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
@@ -27,7 +28,7 @@ import { git } from './git.js'
27
28
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
28
29
  // (materialize writes every harness's artifacts).
29
30
 
30
- export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'opencode-headless' | 'pi-headless'
31
+ export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
31
32
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
32
33
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
33
34
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
@@ -47,6 +48,9 @@ export interface Harness {
47
48
  // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
48
49
  // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
49
50
  readonly headless: boolean
51
+ // whether the launch command intentionally exits after its first turn instead of owning a resident process.
52
+ // One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
53
+ readonly launchOneShot?: boolean
50
54
  // whether this harness persists a native event stream that the console may expose as an optional
51
55
  // full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
52
56
  readonly messageStream: boolean
@@ -446,7 +450,23 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
446
450
  bypassProbe.set(binary, ok)
447
451
  return ok
448
452
  }
449
- export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
453
+
454
+ // Headless adapters all feed non-zero ephemeral turn exits through one state writer. Keep this reporter at the
455
+ // adapter seam: controllers may call it directly, while shell-homed turns use headlessTurnFailureShell below.
456
+ export async function reportHeadlessTurnExit(id: string, harness: string, code: number | null, cwd = process.cwd()): Promise<void> {
457
+ if (code === 0) return
458
+ const exitCode = code === null ? 'signal' : String(code)
459
+ try {
460
+ await pexec(SPEX, ['internal', 'session-turn-fail', id, harness, exitCode], { cwd, env: process.env })
461
+ } catch (error) {
462
+ console.error(`[spex ${harness}] could not record turn failure for ${id}: ${(error as Error).message}`)
463
+ }
464
+ }
465
+
466
+ export function headlessTurnFailureShell(harness: string): string {
467
+ return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc" || true`
468
+ }
469
+ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
450
470
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
451
471
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
452
472
  // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
@@ -516,11 +536,21 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
516
536
  // rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
517
537
  `if [ "$1" = "--resume" ]; then`,
518
538
  ` tid=$2`,
539
+ ...(attachTui ? [] : [
540
+ // A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
541
+ // a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
542
+ `elif [ "$#" -eq 0 ]; then`,
543
+ ` exit 0`,
544
+ ]),
519
545
  `else`,
520
- ` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@") || exit 1`,
546
+ ` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@")`,
547
+ ` __spex_rc=$?`,
548
+ ...(attachTui ? [` [ "$__spex_rc" -eq 0 ] || exit 1`] : [
549
+ ` if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('codex-headless')}; exit "$__spex_rc"; fi`,
550
+ ]),
521
551
  `fi`,
522
552
  `[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
523
- `exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`,
553
+ ...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
524
554
  ].join('\n')
525
555
  return `bash -lc ${shQuote(script)} spexcode-codex`
526
556
  }
@@ -1230,6 +1260,24 @@ export const codexHarness: Harness = {
1230
1260
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
1231
1261
  }
1232
1262
 
1263
+ // Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
1264
+ // while launch only runs the backend-owned thread/start + first turn. There is no TUI to attach after that turn;
1265
+ // the shared project app-server keeps the thread addressable and idle sends use the inherited JSON-RPC channel.
1266
+ export const codexHeadlessHarness: Harness = {
1267
+ ...codexHarness,
1268
+ id: 'codex-headless',
1269
+ headless: true,
1270
+ launchOneShot: true,
1271
+ messageStream: false,
1272
+ launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
1273
+ // Record-backed liveness is the family contract for sleeping headless threads. A broken app-server or missing
1274
+ // thread is surfaced by the inherited delivery call rather than converted into a speculative offline state.
1275
+ liveness: () => 'online',
1276
+ // There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
1277
+ // runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
1278
+ resumeArg: () => '',
1279
+ }
1280
+
1233
1281
  // @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
1234
1282
  // the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
1235
1283
  // lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
@@ -1367,7 +1415,7 @@ export const opencodeHeadlessHarness: Harness = {
1367
1415
  }
1368
1416
 
1369
1417
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
1370
- export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness]
1418
+ export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
1371
1419
 
1372
1420
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
1373
1421
  export const defaultHarness: Harness = claudeHarness
@@ -223,6 +223,7 @@ export async function reconcileProjects(): Promise<ProjectEntry[]> {
223
223
 
224
224
  const byId = new Map<string, ProjectEntry>()
225
225
  const push = (root: string) => {
226
+ if (!existsSync(root)) return
226
227
  const projectId = encodeProject(root)
227
228
  if (byId.has(projectId)) return // encodeProject is lossy; first root wins a (pathological) collision
228
229
  const active = live.get(root) ?? null
@@ -1,17 +1,50 @@
1
1
  import { execFile } from 'node:child_process'
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir, userInfo } from 'node:os'
4
+ import { join } from 'node:path'
2
5
  import { promisify } from 'node:util'
6
+ import { fileURLToPath } from 'node:url'
3
7
  import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
4
8
 
5
9
  const pexec = promisify(execFile)
10
+ const SPEX = join(fileURLToPath(new URL('..', import.meta.url)), 'bin', 'spex.mjs')
11
+ const WAKE_EARLY_EXIT_MS = 5_000
12
+ const OUTCOME_POLL_MS = 25
6
13
 
7
14
  const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
15
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
16
+ const turnFailureShell = (swallow: boolean) =>
17
+ `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote('opencode-headless')} "$__spex_rc"${swallow ? ' || true' : ''}`
18
+
19
+ function accountLoginShell(): string {
20
+ try { return userInfo().shell || '' } catch { return '' }
21
+ }
8
22
 
9
23
  // A headless turn owns the pane only while `opencode run` is alive. Returning to a shell keeps the tmux
10
24
  // window as the session's durable home without adding a resident controller or an stdin bridge.
11
- function turnHome(command: string): string {
25
+ function turnHome(command: string, outcomePath?: string): string {
26
+ const outcomeSetup = outcomePath ? [
27
+ `__spex_outcome_path=${shQuote(outcomePath)}`,
28
+ `__spex_outcome_tmp=${shQuote(`${outcomePath}.tmp`)}`,
29
+ '__spex_outcome() { printf \'%s\\n\' "$1" > "$__spex_outcome_tmp" && mv -f "$__spex_outcome_tmp" "$__spex_outcome_path"; }',
30
+ '__spex_outcome "running:$$" || { printf "[spex opencode-headless] could not create turn outcome marker\\n" >&2; exit 125; }',
31
+ ] : []
32
+ const failure = outcomePath ? [
33
+ '__spex_cas_rc=0',
34
+ 'if [ "$__spex_rc" -ne 0 ]; then',
35
+ ' __spex_outcome "reporting:$$:$__spex_rc" || true',
36
+ ` ${turnFailureShell(false)}`,
37
+ ' __spex_cas_rc=$?',
38
+ 'fi',
39
+ '__spex_outcome "exit:$__spex_rc:cas:$__spex_cas_rc" || printf "[spex opencode-headless] could not finalize turn outcome marker\\n" >&2',
40
+ ] : [
41
+ `if [ "$__spex_rc" -ne 0 ]; then ${turnFailureShell(true)}; fi`,
42
+ ]
12
43
  const script = [
44
+ ...outcomeSetup,
13
45
  command,
14
46
  '__spex_rc=$?',
47
+ ...failure,
15
48
  '[ "$__spex_rc" -eq 0 ] || printf "[spex opencode-headless] turn exited rc=%s\\n" "$__spex_rc" >&2',
16
49
  'exec "${SHELL:-/bin/sh}"',
17
50
  ].join('\n')
@@ -21,14 +54,19 @@ function turnHome(command: string): string {
21
54
  // Launcher profiles carry a base executable plus its configured flags (`opencode --auto`). OpenCode parses
22
55
  // `run` as a subcommand, so it must sit between those two halves (`opencode run --auto`), not at the tail.
23
56
  function runPrelude(opencodeCmd: string): string[] {
57
+ const accountShell = accountLoginShell()
24
58
  return [
59
+ '__spex_login_shell="${SHELL:-}"',
60
+ `[ -n "$__spex_login_shell" ] || __spex_login_shell=${shQuote(accountShell)}`,
25
61
  `__spex_cmd=(${opencodeCmd})`,
26
62
  '__spex_env=()',
27
63
  'while [ "${#__spex_cmd[@]}" -gt 0 ] && [[ "${__spex_cmd[0]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do',
28
64
  ' __spex_env+=("${__spex_cmd[0]}")',
29
65
  ' __spex_cmd=("${__spex_cmd[@]:1}")',
30
66
  'done',
31
- '__spex_run() { env "${__spex_env[@]}" "${__spex_cmd[0]}" run "${__spex_cmd[@]:1}" "$@"; }',
67
+ // Resolve OpenCode in the same login + interactive environment the user relies on. The launcher-leading
68
+ // assignments are applied after shell startup, so its explicit per-session config still wins.
69
+ '__spex_run() { [ -n "$__spex_login_shell" ] || { printf "[spex opencode-headless] no login shell could be resolved - turn NOT started\\n" >&2; return 126; }; "$__spex_login_shell" -ilc \'exec env "$@"\' spexcode-opencode-headless "${__spex_env[@]}" "${__spex_cmd[0]}" run "${__spex_cmd[@]:1}" "$@"; }',
32
70
  ]
33
71
  }
34
72
 
@@ -53,7 +91,12 @@ export function opencodeHeadlessLaunchCommand(opencodeCmd = 'opencode'): string
53
91
  return turnHome(script)
54
92
  }
55
93
 
56
- export function opencodeHeadlessWakeCommand(opencodeCmd: string, harnessSessionId: string | null | undefined, text: string): string {
94
+ export function opencodeHeadlessWakeCommand(
95
+ opencodeCmd: string,
96
+ harnessSessionId: string | null | undefined,
97
+ text: string,
98
+ outcomePath?: string,
99
+ ): string {
57
100
  const resume = harnessSessionId ? [
58
101
  `export SPEXCODE_OPENCODE_RESUME_ID=${shQuote(harnessSessionId)}`,
59
102
  'unset SPEXCODE_OPENCODE_CONTINUE',
@@ -63,7 +106,44 @@ export function opencodeHeadlessWakeCommand(opencodeCmd: string, harnessSessionI
63
106
  'export SPEXCODE_OPENCODE_CONTINUE=1',
64
107
  `__spex_run --continue ${shQuote(text)}`,
65
108
  ]
66
- return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'))
109
+ return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'), outcomePath)
110
+ }
111
+
112
+ type TurnOutcome =
113
+ | { state: 'running'; pid: number }
114
+ | { state: 'reporting'; pid: number; code: number }
115
+ | { state: 'exit'; code: number; casCode: number }
116
+ | { state: 'invalid' }
117
+
118
+ function readTurnOutcome(path: string): TurnOutcome | undefined {
119
+ let value: string
120
+ try { value = readFileSync(path, 'utf8').trim() } catch (error) {
121
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
122
+ throw error
123
+ }
124
+ let match = /^running:(\d+)$/.exec(value)
125
+ if (match) return { state: 'running', pid: Number(match[1]) }
126
+ match = /^reporting:(\d+):(-?\d+)$/.exec(value)
127
+ if (match) return { state: 'reporting', pid: Number(match[1]), code: Number(match[2]) }
128
+ match = /^exit:(-?\d+):cas:(\d+)$/.exec(value)
129
+ if (match) return { state: 'exit', code: Number(match[1]), casCode: Number(match[2]) }
130
+ return { state: 'invalid' }
131
+ }
132
+
133
+ function turnExited(rec: HarnessDeliveryRecord, outcome: Extract<TurnOutcome, { state: 'exit' }>): DispatchResult {
134
+ if (outcome.code === 0) return { ok: true }
135
+ const cas = outcome.casCode === 0 ? '' : `; error CAS reporter also exited with code ${outcome.casCode}`
136
+ return {
137
+ ok: false,
138
+ error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - prompt delivery FAILED`,
139
+ }
140
+ }
141
+
142
+ function pidAlive(pid: number): boolean {
143
+ if (!Number.isInteger(pid) || pid <= 0) return false
144
+ try { process.kill(pid, 0); return true } catch (error) {
145
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
146
+ }
67
147
  }
68
148
 
69
149
  export async function spawnOpenCodeHeadlessTurn(
@@ -73,6 +153,11 @@ export async function spawnOpenCodeHeadlessTurn(
73
153
  socketPath: string,
74
154
  ): Promise<DispatchResult> {
75
155
  if (!rec.worktreePath) return { ok: false, error: `opencode-headless session ${rec.session} has no worktree path - turn NOT started` }
156
+ let outcomeDir: string
157
+ try { outcomeDir = mkdtempSync(join(tmpdir(), 'spexcode-oh-turn-')) } catch (error) {
158
+ return { ok: false, error: `opencode-headless could not prepare turn confirmation for session ${rec.session}: ${(error as Error).message}` }
159
+ }
160
+ const outcomePath = join(outcomeDir, 'exit-code')
76
161
  const tmuxSock = process.env.SPEXCODE_TMUX || 'spexcode'
77
162
  const args = [
78
163
  '-L', tmuxSock, 'respawn-pane', '-k', '-t', rec.session, '-c', rec.worktreePath,
@@ -84,12 +169,41 @@ export async function spawnOpenCodeHeadlessTurn(
84
169
  const value = process.env[name]
85
170
  if (value) args.push('-e', `${name}=${value}`)
86
171
  }
87
- args.push(opencodeHeadlessWakeCommand(opencodeCmd, rec.harnessSessionId, text))
172
+ args.push(opencodeHeadlessWakeCommand(opencodeCmd, rec.harnessSessionId, text, outcomePath))
88
173
  try {
89
174
  await pexec('tmux', args, { timeout: 5_000 })
175
+ const deadline = Date.now() + WAKE_EARLY_EXIT_MS
176
+ for (;;) {
177
+ const outcome = readTurnOutcome(outcomePath)
178
+ if (outcome?.state === 'exit') return turnExited(rec, outcome)
179
+ if (outcome?.state === 'invalid') {
180
+ return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
181
+ }
182
+ if (Date.now() >= deadline) break
183
+ await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()))
184
+ }
185
+
186
+ const first = readTurnOutcome(outcomePath)
187
+ if (first?.state === 'exit') return turnExited(rec, first)
188
+ if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - prompt delivery FAILED` }
189
+ if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
190
+ if (first.state === 'reporting') {
191
+ return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - prompt delivery FAILED` }
192
+ }
193
+ if (!pidAlive(first.pid)) {
194
+ return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - prompt delivery FAILED` }
195
+ }
196
+ await sleep(OUTCOME_POLL_MS)
197
+ const settled = readTurnOutcome(outcomePath)
198
+ if (settled?.state === 'exit') return turnExited(rec, settled)
199
+ if (settled?.state !== 'running' || settled.pid !== first.pid || !pidAlive(settled.pid)) {
200
+ return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - prompt delivery FAILED` }
201
+ }
90
202
  return { ok: true }
91
203
  } catch (error) {
92
204
  const detail = error instanceof Error ? error.message : String(error)
93
205
  return { ok: false, error: `opencode-headless could not start a turn for session ${rec.session}: ${detail}` }
206
+ } finally {
207
+ rmSync(outcomeDir, { recursive: true, force: true })
94
208
  }
95
209
  }
@@ -120,10 +120,14 @@ export const SpexcodePlugin = async (ctx) => {
120
120
  const sid = p.sessionID || ""
121
121
  if (sid && rootSession && sid !== rootSession) return // a subagent going idle is not this worker's Stop
122
122
  adopt(sid)
123
- // the stop-gate loop closes in-process via the runtime's dispatchStop: stop_hook_active rides the
124
- // payload (the gate's loop-termination bit) and the gate's parsed reason re-enters as a follow-up
125
- // prompt; an inject the host can no longer take is reported loud.
126
- await rt.dispatchStop((reason) => injectPrompt(reason), "blocked by a spexcode hook")
123
+ // Start the Stop dispatch now, but RETURN this idle callback before a blocked verdict injects its
124
+ // follow-up turn. Awaiting client.session.prompt here re-enters opencode before it has published the
125
+ // just-finished assistant text: the continuation can declare success while the requested final answer
126
+ // disappears. This is opencode host scheduling, not shared dispatchStop semantics (pi correctly awaits
127
+ // its agent_end continuation). dispatchStop catches inject failures; this final catch keeps substrate
128
+ // failures loud without throwing them into opencode after the event callback has returned.
129
+ void rt.dispatchStop((reason) => injectPrompt(reason), "blocked by a spexcode hook")
130
+ .catch((e) => console.error("spexcode: Stop dispatch failed: " + String(e)))
127
131
  }
128
132
  },
129
133
  "chat.message": async (input, output) => {
@@ -171,6 +171,7 @@ export class PiHeadlessController {
171
171
  childProcess.once('close', (code) => {
172
172
  if (this.child === turn) this.child = null
173
173
  resolveExit(code)
174
+ if (code !== 0 && !this.closing) void import('./harness.js').then(({ reportHeadlessTurnExit }) => reportHeadlessTurnExit(this.id, 'pi-headless', code, this.cwd))
174
175
  })
175
176
  await withTimeout(new Promise<void>((resolve, reject) => {
176
177
  childProcess.once('spawn', () => resolve())