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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +116 -66
- package/spec-cli/src/cli.ts +221 -72
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +965 -70
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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)
|
|
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], {
|
|
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
|
-
|
|
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.
|
|
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
|
|