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.
- package/package.json +1 -1
- package/spec-cli/src/claude-headless.ts +105 -16
- package/spec-cli/src/cli.ts +13 -2
- package/spec-cli/src/harness.ts +21 -1
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/opencode-headless.ts +119 -5
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +1 -0
- package/spec-cli/src/pty-bridge.ts +31 -4
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/session-timeline.ts +23 -17
- package/spec-cli/src/sessions.ts +25 -4
- package/spec-dashboard/dist/assets/{Dashboard-C_w_wdk5.js → Dashboard-CTAuTyZ3.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-5_nfIYll.js → EvalsPage-KbMMownG.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-By-u--95.js → IssuesPage-DmyLb9Rj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-CVEwjHr9.js → MobileApp-D2RZGt4Z.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-BqgvzMJD.js → Modal-3brXUhM0.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CadAKuSy.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +1 -0
- package/spec-dashboard/dist/assets/{SessionInterface-Bh3vq8SU.js → SessionInterface-BtrzlOPs.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionWindow-BuJ5mzjC.js → SessionWindow-BWH5O0jh.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-B8KFocsz.js → Settings-COgdKTJB.js} +1 -1
- package/spec-dashboard/dist/assets/{TimelineChat-K0wdlweB.js → TimelineChat-DQ21GSJK.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BKaTHjmU.js → index-D6HBvKkJ.js} +2 -2
- package/spec-dashboard/dist/assets/{index-DcnCaBAC.css → index-DFdlYy4H.css} +1 -1
- package/spec-dashboard/dist/index.html +2 -2
- 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.
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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)
|
|
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], {
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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:
|
|
220
|
-
|
|
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`,
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -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" "$@")
|
|
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"`] : []),
|
package/spec-cli/src/host.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
}
|
package/spec-cli/src/opencode.ts
CHANGED
|
@@ -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
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|