spexcode 0.5.1 → 0.5.3

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 (155) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
@@ -1,17 +1,24 @@
1
- import { appendFileSync, mkdirSync, rmSync } from 'node:fs'
2
- import { createConnection, createServer, type Server, type Socket } from 'node:net'
1
+ import { rmSync } from 'node:fs'
2
+ import { createServer, type Server, type Socket } from 'node:net'
3
3
  import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { join } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
9
+ import { controlRequest, withTimeout } from './headless-controller.js'
10
+ import { shQuote } from './sh.js'
9
11
 
10
- type ControlRequest = { type: 'deliver'; text: string } | { type: 'interrupt' }
12
+ type ControlRequest = { type: 'deliver'; text: string; mode: 'steer' | 'wake' } | { type: 'interrupt' }
13
+ type ClaudeHeadlessDeliveryRecord = HarnessDeliveryRecord & { status?: string }
11
14
  type ChildTurn = {
12
15
  process: ChildProcessWithoutNullStreams
13
16
  active: boolean
17
+ completed: boolean
14
18
  exited: Promise<number | null>
19
+ teardown: Promise<void> | null
20
+ result: Promise<void>
21
+ sawResult: () => void
15
22
  firstEvent: Promise<void>
16
23
  sawFirstEvent: () => void
17
24
  interruptAcks: Map<string, () => void>
@@ -19,11 +26,14 @@ type ChildTurn = {
19
26
 
20
27
  const PKG = fileURLToPath(new URL('..', import.meta.url))
21
28
  const SPEX = join(PKG, 'bin', 'spex.mjs')
22
- const CONTROL_TIMEOUT_MS = 30_000
29
+ const CONTROL_TIMEOUT_MS = 60_000
23
30
  const START_TIMEOUT_MS = 30_000
24
31
  const INTERRUPT_TIMEOUT_MS = 10_000
32
+ const RESULT_WAIT_MS = 20_000
33
+ const RESULT_EXIT_GRACE_MS = 500
34
+ const TERM_EXIT_GRACE_MS = 500
35
+ const KILL_EXIT_GRACE_MS = 2_000
25
36
 
26
- const shQuote = (s: string) => `'${s.replace(/'/g, `'\''`)}'`
27
37
  const userEvent = (text: string) => JSON.stringify({
28
38
  type: 'user',
29
39
  message: { role: 'user', content: [{ type: 'text', text }] },
@@ -35,70 +45,31 @@ export function claudeHeadlessLaunchCommand(id: string, runtimeDir: string, clau
35
45
  return [shQuote(SPEX), 'internal', 'claude-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(claudeCmd), '--'].join(' ')
36
46
  }
37
47
 
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
- )
48
+ export const deliverViaClaudeHeadless = (rec: ClaudeHeadlessDeliveryRecord, text: string) =>
49
+ controlRequest(claudeHeadlessSock(rec.session), { type: 'deliver', text, mode: rec.status === 'active' ? 'steer' : 'wake' }, {
50
+ name: 'claude-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
51
+ rejected: 'claude-headless control rejected the request',
45
52
  })
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
53
 
82
54
  export const interruptClaudeHeadless = (rec: HarnessDeliveryRecord) =>
83
- controlRequest(rec.session, { type: 'interrupt' })
55
+ controlRequest(claudeHeadlessSock(rec.session), { type: 'interrupt' }, {
56
+ name: 'claude-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
57
+ rejected: 'claude-headless control rejected the request',
58
+ })
84
59
 
85
60
  export class ClaudeHeadlessController {
86
61
  private server: Server | null = null
87
62
  private child: ChildTurn | null = null
88
63
  private controlQueue: Promise<void> = Promise.resolve()
89
64
  private closing = false
90
- private readonly messagesPath: string
91
65
  private readonly socketPath: string
92
66
 
93
67
  constructor(
94
68
  private readonly id: string,
95
- runtimeDir: string,
69
+ _runtimeDir: string,
96
70
  private readonly claudeCmd: string,
97
71
  private readonly cwd = process.cwd(),
98
72
  ) {
99
- const dir = join(runtimeDir, 'sessions', id)
100
- mkdirSync(dir, { recursive: true })
101
- this.messagesPath = join(dir, 'messages.ndjson')
102
73
  this.socketPath = claudeHeadlessSock(id)
103
74
  }
104
75
 
@@ -121,12 +92,15 @@ export class ClaudeHeadlessController {
121
92
  if (this.closing) return
122
93
  this.closing = true
123
94
  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 */ }
95
+ try {
96
+ if (child) await this.ensureTurnExit(child)
97
+ } finally {
98
+ await new Promise<void>((resolve) => {
99
+ if (!this.server) return resolve()
100
+ this.server.close(() => resolve())
101
+ })
102
+ try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
103
+ }
130
104
  }
131
105
 
132
106
  private accept(socket: Socket): void {
@@ -157,11 +131,17 @@ export class ClaudeHeadlessController {
157
131
  if (request.type === 'deliver') {
158
132
  if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
159
133
  const current = this.child
160
- if (current?.active && current.process.stdin.writable) {
134
+ if (request.mode === 'steer' && current?.active && current.process.stdin.writable) {
161
135
  await this.writeLine(current, userEvent(request.text))
162
136
  return { ok: true }
163
137
  }
164
- if (current) await withTimeout(current.exited, 5_000, 'previous claude-headless turn did not exit after its result')
138
+ if (current?.active) {
139
+ await Promise.race([
140
+ withTimeout(current.result, RESULT_WAIT_MS, 'previous claude-headless turn did not reach its result before idle wake'),
141
+ current.exited.then((code) => { throw new Error(`previous claude-headless turn exited before its result (code ${code ?? 'signal'})`) }),
142
+ ])
143
+ }
144
+ if (current) await this.ensureTurnExit(current)
165
145
  await this.spawnTurn(request.text, true)
166
146
  return { ok: true }
167
147
  }
@@ -192,12 +172,30 @@ export class ClaudeHeadlessController {
192
172
  const mode = resume ? ['--resume', this.id] : ['--session-id', this.id]
193
173
  const args = ['-p', ...mode, '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']
194
174
  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'] })
175
+ const childProcess = spawn('/bin/sh', ['-lc', command], {
176
+ cwd: this.cwd,
177
+ env: process.env,
178
+ detached: true,
179
+ stdio: ['pipe', 'pipe', 'pipe'],
180
+ })
196
181
  let sawFirstEvent!: () => void
197
182
  const firstEvent = new Promise<void>((resolve) => { sawFirstEvent = resolve })
198
183
  let resolveExit!: (code: number | null) => void
199
184
  const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
200
- const turn: ChildTurn = { process: childProcess, active: true, exited, firstEvent, sawFirstEvent, interruptAcks: new Map() }
185
+ let sawResult!: () => void
186
+ const result = new Promise<void>((resolve) => { sawResult = resolve })
187
+ const turn: ChildTurn = {
188
+ process: childProcess,
189
+ active: true,
190
+ completed: false,
191
+ exited,
192
+ teardown: null,
193
+ result,
194
+ sawResult,
195
+ firstEvent,
196
+ sawFirstEvent,
197
+ interruptAcks: new Map(),
198
+ }
201
199
  this.child = turn
202
200
  let stdoutBuffer = ''
203
201
  childProcess.stdout.setEncoding('utf8')
@@ -210,7 +208,6 @@ export class ClaudeHeadlessController {
210
208
  const line = stdoutBuffer.slice(0, nl)
211
209
  stdoutBuffer = stdoutBuffer.slice(nl + 1)
212
210
  const nativeLine = `${line}\n`
213
- appendFileSync(this.messagesPath, nativeLine)
214
211
  globalThis.process.stdout.write(nativeLine)
215
212
  turn.sawFirstEvent()
216
213
  this.observeEvent(turn, line)
@@ -224,6 +221,9 @@ export class ClaudeHeadlessController {
224
221
  if (stdoutBuffer) console.error('[spex claude-headless] dropped a partial non-line stdout event')
225
222
  if (this.child === turn) this.child = null
226
223
  resolveExit(code)
224
+ if (!turn.completed && code !== 0 && !this.closing) {
225
+ void import('./harness.js').then(({ reportHeadlessTurnExit }) => reportHeadlessTurnExit(this.id, 'claude-headless', code, this.cwd))
226
+ }
227
227
  })
228
228
  await this.writeLine(turn, userEvent(text))
229
229
  await Promise.race([
@@ -238,9 +238,59 @@ export class ClaudeHeadlessController {
238
238
  if (event?.type === 'control_response' && typeof event?.response?.request_id === 'string') {
239
239
  turn.interruptAcks.get(event.response.request_id)?.()
240
240
  }
241
- if (event?.type === 'result') {
241
+ if (event?.type === 'result' && !turn.completed) {
242
+ turn.completed = true
242
243
  turn.active = false
243
- turn.process.stdin.end()
244
+ turn.sawResult()
245
+ void this.ensureTurnExit(turn).catch((error) => {
246
+ console.error(`[spex claude-headless] completed turn teardown failed: ${(error as Error).message}`)
247
+ })
248
+ }
249
+ }
250
+
251
+ private ensureTurnExit(turn: ChildTurn): Promise<void> {
252
+ turn.teardown ??= this.terminateTurn(turn)
253
+ return turn.teardown
254
+ }
255
+
256
+ private async terminateTurn(turn: ChildTurn): Promise<void> {
257
+ if (turn.completed) {
258
+ if (turn.process.stdin.writable) turn.process.stdin.end()
259
+ if (await this.waitForTurnExit(turn, RESULT_EXIT_GRACE_MS)) return
260
+ this.signalTurn(turn, 'SIGKILL')
261
+ await withTimeout(
262
+ turn.exited,
263
+ KILL_EXIT_GRACE_MS,
264
+ `claude-headless completed turn process group ${turn.process.pid ?? 'unknown'} did not exit after SIGKILL`,
265
+ )
266
+ return
267
+ }
268
+ this.signalTurn(turn, 'SIGTERM')
269
+ if (await this.waitForTurnExit(turn, TERM_EXIT_GRACE_MS)) return
270
+ this.signalTurn(turn, 'SIGKILL')
271
+ await withTimeout(
272
+ turn.exited,
273
+ KILL_EXIT_GRACE_MS,
274
+ `claude-headless turn process group ${turn.process.pid ?? 'unknown'} did not exit after SIGKILL`,
275
+ )
276
+ }
277
+
278
+ private async waitForTurnExit(turn: ChildTurn, timeoutMs: number): Promise<boolean> {
279
+ try {
280
+ await withTimeout(turn.exited, timeoutMs, 'turn exit grace elapsed')
281
+ return true
282
+ } catch {
283
+ return false
284
+ }
285
+ }
286
+
287
+ private signalTurn(turn: ChildTurn, signal: NodeJS.Signals): void {
288
+ const pid = turn.process.pid
289
+ try {
290
+ if (pid) process.kill(-pid, signal)
291
+ else turn.process.kill(signal)
292
+ } catch (error) {
293
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
244
294
  }
245
295
  }
246
296