spexcode 0.5.1 → 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 (28) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/claude-headless.ts +105 -16
  3. package/spec-cli/src/cli.ts +13 -2
  4. package/spec-cli/src/harness.ts +21 -1
  5. package/spec-cli/src/host.ts +1 -0
  6. package/spec-cli/src/opencode-headless.ts +119 -5
  7. package/spec-cli/src/opencode.ts +8 -4
  8. package/spec-cli/src/pi-headless.ts +1 -0
  9. package/spec-cli/src/pty-bridge.ts +31 -4
  10. package/spec-cli/src/pty-helper.mjs +16 -6
  11. package/spec-cli/src/pty-native-helper.mjs +22 -0
  12. package/spec-cli/src/session-timeline.ts +23 -17
  13. package/spec-cli/src/sessions.ts +25 -4
  14. package/spec-dashboard/dist/assets/{Dashboard-C_w_wdk5.js → Dashboard-CTAuTyZ3.js} +3 -3
  15. package/spec-dashboard/dist/assets/{EvalsPage-5_nfIYll.js → EvalsPage-KbMMownG.js} +2 -2
  16. package/spec-dashboard/dist/assets/{IssuesPage-By-u--95.js → IssuesPage-DmyLb9Rj.js} +1 -1
  17. package/spec-dashboard/dist/assets/{MobileApp-CVEwjHr9.js → MobileApp-D2RZGt4Z.js} +2 -2
  18. package/spec-dashboard/dist/assets/{Modal-BqgvzMJD.js → Modal-3brXUhM0.js} +1 -1
  19. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CadAKuSy.js} +1 -1
  20. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +1 -0
  21. package/spec-dashboard/dist/assets/{SessionInterface-Bh3vq8SU.js → SessionInterface-BtrzlOPs.js} +1 -1
  22. package/spec-dashboard/dist/assets/{SessionWindow-BuJ5mzjC.js → SessionWindow-BWH5O0jh.js} +1 -1
  23. package/spec-dashboard/dist/assets/{Settings-B8KFocsz.js → Settings-COgdKTJB.js} +1 -1
  24. package/spec-dashboard/dist/assets/{TimelineChat-K0wdlweB.js → TimelineChat-DQ21GSJK.js} +1 -1
  25. package/spec-dashboard/dist/assets/{index-BKaTHjmU.js → index-D6HBvKkJ.js} +2 -2
  26. package/spec-dashboard/dist/assets/{index-DcnCaBAC.css → index-DFdlYy4H.css} +1 -1
  27. package/spec-dashboard/dist/index.html +2 -2
  28. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.1",
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",
@@ -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`,
@@ -450,6 +450,22 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
450
450
  bypassProbe.set(binary, ok)
451
451
  return ok
452
452
  }
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
+ }
453
469
  export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
454
470
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
455
471
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
@@ -527,7 +543,11 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
527
543
  ` exit 0`,
528
544
  ]),
529
545
  `else`,
530
- ` 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
+ ]),
531
551
  `fi`,
532
552
  `[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
533
553
  ...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
@@ -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())
@@ -20,6 +20,7 @@ type Subscription = {
20
20
  bridge?: Bridge
21
21
  lingerTimer?: ReturnType<typeof setTimeout>
22
22
  restoreTimer?: ReturnType<typeof setTimeout>
23
+ startupError?: string
23
24
  }
24
25
 
25
26
  type Bridge = {
@@ -37,6 +38,7 @@ type Bridge = {
37
38
  refreshRunning: boolean
38
39
  refreshOffset?: number
39
40
  deliveryTimer?: ReturnType<typeof setTimeout>
41
+ startupError?: string
40
42
  }
41
43
 
42
44
  const subscribers = new Map<string, Map<Viewer, Subscription>>()
@@ -103,6 +105,8 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
103
105
  const ready = line.match(/^READY (\d+)$/)
104
106
  if (ready) {
105
107
  bridge.ptyPid = Number(ready[1])
108
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
109
+ if (subscription) subscription.startupError = undefined
106
110
  if (bridge.delivery === 'initial') {
107
111
  armDeliveryBoundary(bridge)
108
112
  queueRefresh(bridge)
@@ -117,11 +121,28 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
117
121
  queueRefresh(bridge)
118
122
  }
119
123
  } else if (line) {
124
+ const failed = line.match(/^ERROR (.+)$/)
125
+ if (failed) reportStartupError(bridge, failed[1])
120
126
  console.error(`[terminal helper ${bridge.id}/${bridge.ptyPid ?? 'starting'}] ${line}`)
121
127
  }
122
128
  }
123
129
  }
124
130
 
131
+ function reportStartupError(bridge: Bridge, detail: string): void {
132
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
133
+ if (!subscription || subscription.bridge !== bridge) return
134
+ const message = boundedStartupError(detail)
135
+ bridge.startupError = message
136
+ if (!message || subscription.startupError === message) return
137
+ subscription.startupError = message
138
+ deliver(bridge, Buffer.from(`\r\n[SpexCode terminal unavailable] ${message}\r\n`, 'utf8'))
139
+ }
140
+
141
+ function boundedStartupError(detail: unknown): string {
142
+ return (detail instanceof Error ? detail.message : String(detail))
143
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500) || 'native PTY failed to start'
144
+ }
145
+
125
146
  function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, cols: number, rows: number): { bridge: Bridge | null; created: boolean } {
126
147
  if (subscription.bridge) return { bridge: subscription.bridge, created: false }
127
148
  let proc: ChildProcessWithoutNullStreams | undefined
@@ -130,8 +151,13 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
130
151
  stdio: ['pipe', 'pipe', 'pipe'],
131
152
  env: process.env,
132
153
  })
133
- } catch {
154
+ } catch (error) {
134
155
  try { proc?.kill() } catch { /* spawn did not complete */ }
156
+ const detail = boundedStartupError(error)
157
+ if (subscription.startupError !== detail) {
158
+ subscription.startupError = detail
159
+ try { viewer.send(Buffer.from(`\r\n[SpexCode terminal unavailable] ${detail}\r\n`, 'utf8')) } catch { /* socket closed */ }
160
+ }
135
161
  return { bridge: null, created: false }
136
162
  }
137
163
  const bridge: Bridge = {
@@ -142,18 +168,19 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
142
168
  proc.stdout.on('data', (data: Buffer) => onHelperOutput(bridge, data))
143
169
  proc.stderr.on('data', (data: Buffer) => onHelperStderr(bridge, data))
144
170
  let reaped = false
145
- const gone = () => {
171
+ const gone = (detail?: string) => {
146
172
  if (reaped) return
147
173
  reaped = true
148
174
  const current = currentSubscription(id, viewer)
149
175
  if (current?.bridge !== bridge) return
176
+ if (!bridge.ptyPid && !bridge.startupError) reportStartupError(bridge, detail || 'helper exited before native PTY startup')
150
177
  current.bridge = undefined
151
178
  clearDelivery(bridge)
152
179
  try { bridge.proc.kill() } catch { /* already gone */ }
153
180
  scheduleRestore(id, viewer, current)
154
181
  }
155
- proc.on('exit', gone)
156
- proc.on('error', gone)
182
+ proc.on('exit', (code, signal) => gone(`helper exited before native PTY startup (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`))
183
+ proc.on('error', (error) => gone(`helper process failed: ${error.message}`))
157
184
  return { bridge, created: true }
158
185
  }
159
186
 
@@ -1,5 +1,6 @@
1
1
  import * as pty from 'node-pty'
2
2
  import { execFileSync } from 'node:child_process'
3
+ import { ensureExecutableIfPresent, nodePtySpawnHelperPath } from './pty-native-helper.mjs'
3
4
 
4
5
  const [id, colsArg, rowsArg] = process.argv.slice(2)
5
6
  const cols = Number(colsArg)
@@ -40,12 +41,21 @@ try {
40
41
  }
41
42
  } catch { /* attach below fails loudly if the tmux server/session is unavailable */ }
42
43
 
43
- const terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
44
- name: 'xterm-256color',
45
- cols,
46
- rows,
47
- env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
48
- })
44
+ let terminal
45
+ try {
46
+ ensureExecutableIfPresent(nodePtySpawnHelperPath(pty.native))
47
+ terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
48
+ name: 'xterm-256color',
49
+ cols,
50
+ rows,
51
+ env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
52
+ })
53
+ } catch (error) {
54
+ const detail = (error instanceof Error ? error.message : String(error))
55
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500)
56
+ process.stderr.write(`ERROR ${detail || 'native PTY failed to start'}\n`)
57
+ process.exit(1)
58
+ }
49
59
 
50
60
  terminal.onData((data) => process.stdout.write(Buffer.from(data, 'utf8')))
51
61
  terminal.onExit(({ exitCode }) => process.exit(exitCode === 0 ? 0 : 1))
@@ -0,0 +1,22 @@
1
+ import { chmodSync, statSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ const require = createRequire(import.meta.url)
6
+
7
+ export function nodePtySpawnHelperPath(nativeModule) {
8
+ const nativeAddon = Object.values(require.cache).find((loaded) => loaded?.exports === nativeModule)?.filename
9
+ if (!nativeAddon) throw new Error('cannot locate node-pty loaded native addon')
10
+ return join(dirname(nativeAddon), 'spawn-helper')
11
+ }
12
+
13
+ export function ensureExecutableIfPresent(path) {
14
+ let mode
15
+ try {
16
+ mode = statSync(path).mode & 0o777
17
+ } catch (error) {
18
+ if (error?.code === 'ENOENT') return
19
+ throw error
20
+ }
21
+ if ((mode & 0o111) !== 0o111) chmodSync(path, mode | 0o111)
22
+ }