spexcode 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/spec-cli/src/claude-headless.ts +105 -16
- package/spec-cli/src/cli.ts +13 -2
- package/spec-cli/src/codex-headless.ts +13 -0
- package/spec-cli/src/guide.ts +8 -7
- package/spec-cli/src/harness.ts +53 -5
- 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 +29 -21
- package/spec-cli/src/sessions.ts +72 -32
- package/spec-cli/templates/spexcode.json +1 -0
- 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/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Requires Node ≥ 22 and git. This part is plain tooling — no AI involved yet.
|
|
|
82
82
|
```sh
|
|
83
83
|
npm i -g spexcode # installs the `spex` command
|
|
84
84
|
cd your-repo
|
|
85
|
-
spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/, installs hooks, materializes the agent contracts
|
|
85
|
+
spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/, installs hooks, materializes the agent contracts
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
That's the whole adoption. The example lists all the built-in harnesses — remove the ones you don't
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@hono/node-server": "^1.13.7",
|
|
40
40
|
"@hono/node-ws": "^1.0.4",
|
|
41
41
|
"hono": "^4.6.14",
|
|
42
|
-
"node-pty": "
|
|
42
|
+
"node-pty": "1.2.0-beta.14",
|
|
43
43
|
"tsx": "^4.19.2"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
@@ -7,11 +7,16 @@ import { join } from 'node:path'
|
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
9
9
|
|
|
10
|
-
type ControlRequest = { type: 'deliver'; text: string } | { type: 'interrupt' }
|
|
10
|
+
type ControlRequest = { type: 'deliver'; text: string; mode: 'steer' | 'wake' } | { type: 'interrupt' }
|
|
11
|
+
type ClaudeHeadlessDeliveryRecord = HarnessDeliveryRecord & { status?: string }
|
|
11
12
|
type ChildTurn = {
|
|
12
13
|
process: ChildProcessWithoutNullStreams
|
|
13
14
|
active: boolean
|
|
15
|
+
completed: boolean
|
|
14
16
|
exited: Promise<number | null>
|
|
17
|
+
teardown: Promise<void> | null
|
|
18
|
+
result: Promise<void>
|
|
19
|
+
sawResult: () => void
|
|
15
20
|
firstEvent: Promise<void>
|
|
16
21
|
sawFirstEvent: () => void
|
|
17
22
|
interruptAcks: Map<string, () => void>
|
|
@@ -19,9 +24,13 @@ type ChildTurn = {
|
|
|
19
24
|
|
|
20
25
|
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
21
26
|
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
22
|
-
const CONTROL_TIMEOUT_MS =
|
|
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`,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { codexLaunchCommand } from './harness.js'
|
|
2
|
+
|
|
3
|
+
// Codex headless keeps the existing app-server bootstrap and backend-owned thread/start + first turn, but
|
|
4
|
+
// deliberately omits the final interactive `--remote … resume` attach. The shared app-server remains the
|
|
5
|
+
// durable home for the thread; follow-up delivery uses codexHarness' existing JSON-RPC transport.
|
|
6
|
+
export function codexHeadlessLaunchCommand(
|
|
7
|
+
id: string,
|
|
8
|
+
codexCmd = 'codex',
|
|
9
|
+
serverCmd?: string,
|
|
10
|
+
dir?: string,
|
|
11
|
+
): string {
|
|
12
|
+
return codexLaunchCommand(id, codexCmd, serverCmd, dir, false)
|
|
13
|
+
}
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -14,7 +14,7 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
|
|
|
14
14
|
startup commands.)
|
|
15
15
|
|
|
16
16
|
2. Adopt a repo
|
|
17
|
-
cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/ + git hooks (additive, never overwrites)
|
|
17
|
+
cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/ + git hooks (additive, never overwrites)
|
|
18
18
|
--harness is required and has no default — the explicit choice of which harness(es) materialize
|
|
19
19
|
delivers into. The example lists every built-in; drop the ones you don't use (any one id or
|
|
20
20
|
comma-separated subset is valid).
|
|
@@ -333,18 +333,19 @@ harness the adopter SELECTED (--harness), from the template pool
|
|
|
333
333
|
"claude" → { "harness": "claude", "cmd": "claude" }
|
|
334
334
|
"claude-headless" → { "harness": "claude-headless", "cmd": "claude" }
|
|
335
335
|
"codex" → { "harness": "codex", "cmd": "codex" }
|
|
336
|
+
"codex-headless" → { "harness": "codex-headless", "cmd": "codex --yolo" }
|
|
336
337
|
"opencode" → { "harness": "opencode", "cmd": "opencode" }
|
|
337
338
|
"opencode-headless" → { "harness": "opencode-headless", "cmd": "opencode --auto" }
|
|
338
339
|
"pi" → { "harness": "pi", "cmd": "pi" }
|
|
339
340
|
"pi-headless" → { "harness": "pi-headless", "cmd": "pi" }
|
|
340
|
-
The interactive profiles preserve each harness's normal permission model. \`opencode-headless\`
|
|
341
|
-
deliberate seed
|
|
342
|
-
plain. Other automatic-permission commands are NEVER clean-init
|
|
343
|
-
only when that access is intended. To run workers under an auth
|
|
341
|
+
The interactive profiles preserve each harness's normal permission model. \`opencode-headless\` and
|
|
342
|
+
\`codex-headless\` are deliberate seed exceptions: their terminal-free runs require \`opencode --auto\` and
|
|
343
|
+
\`codex --yolo\`; interactive profiles stay plain. Other automatic-permission commands are NEVER clean-init
|
|
344
|
+
defaults: define and select one explicitly only when that access is intended. To run workers under an auth
|
|
344
345
|
wrapper (e.g. reclaude), point a launcher's \`cmd\` at it in spexcode.local.json — there is no environment
|
|
345
346
|
override that rewrites a launcher's command. Add more profiles when a project needs named auth/config-dir or
|
|
346
347
|
permission variants. Shape:
|
|
347
|
-
"launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "opencode-headless" | "pi-headless",
|
|
348
|
+
"launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "codex-headless" | "opencode-headless" | "pi-headless",
|
|
348
349
|
"cmd": "<launch command>" } }
|
|
349
350
|
\`harness\` defaults to "claude"; \`cmd\` is required and embedded whole. A portable plain command may live
|
|
350
351
|
in committed spexcode.json (as the init seeds do). A host-specific command — an absolute wrapper path,
|
|
@@ -453,7 +454,7 @@ Example — tune opt-in health diagnosis without changing the lint gate:
|
|
|
453
454
|
preset the SELECTED init preset — which cumulative .plugins tier \`spex init\` seeds (default
|
|
454
455
|
'default'; seed-time only, read by init.ts).
|
|
455
456
|
harnesses which harness targets \`spex materialize\` delivers into — native ids
|
|
456
|
-
("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"opencode-headless"|"pi-headless") or a
|
|
457
|
+
("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"codex-headless"|"opencode-headless"|"pi-headless") or a
|
|
457
458
|
{ "plugin": "<folder>" } bundle. REQUIRED — there is no default set: \`spex init --harness\`
|
|
458
459
|
stamps the explicit choice, and a missing field fails materialize loud. PERSISTENT and
|
|
459
460
|
git-transactional: the edit takes effect at the next git-native materialize anchor (the commit
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlash
|
|
|
10
10
|
import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
|
|
11
11
|
import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
|
|
12
12
|
import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js'
|
|
13
|
+
import { codexHeadlessLaunchCommand } from './codex-headless.js'
|
|
13
14
|
import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
|
|
14
15
|
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
|
|
15
16
|
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
@@ -27,7 +28,7 @@ import { git } from './git.js'
|
|
|
27
28
|
// payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
|
|
28
29
|
// (materialize writes every harness's artifacts).
|
|
29
30
|
|
|
30
|
-
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'opencode-headless' | 'pi-headless'
|
|
31
|
+
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
31
32
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
|
|
32
33
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
33
34
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
@@ -47,6 +48,9 @@ export interface Harness {
|
|
|
47
48
|
// whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
|
|
48
49
|
// adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
|
|
49
50
|
readonly headless: boolean
|
|
51
|
+
// whether the launch command intentionally exits after its first turn instead of owning a resident process.
|
|
52
|
+
// One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
|
|
53
|
+
readonly launchOneShot?: boolean
|
|
50
54
|
// whether this harness persists a native event stream that the console may expose as an optional
|
|
51
55
|
// full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
|
|
52
56
|
readonly messageStream: boolean
|
|
@@ -446,7 +450,23 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
|
|
|
446
450
|
bypassProbe.set(binary, ok)
|
|
447
451
|
return ok
|
|
448
452
|
}
|
|
449
|
-
|
|
453
|
+
|
|
454
|
+
// Headless adapters all feed non-zero ephemeral turn exits through one state writer. Keep this reporter at the
|
|
455
|
+
// adapter seam: controllers may call it directly, while shell-homed turns use headlessTurnFailureShell below.
|
|
456
|
+
export async function reportHeadlessTurnExit(id: string, harness: string, code: number | null, cwd = process.cwd()): Promise<void> {
|
|
457
|
+
if (code === 0) return
|
|
458
|
+
const exitCode = code === null ? 'signal' : String(code)
|
|
459
|
+
try {
|
|
460
|
+
await pexec(SPEX, ['internal', 'session-turn-fail', id, harness, exitCode], { cwd, env: process.env })
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error(`[spex ${harness}] could not record turn failure for ${id}: ${(error as Error).message}`)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function headlessTurnFailureShell(harness: string): string {
|
|
467
|
+
return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc" || true`
|
|
468
|
+
}
|
|
469
|
+
export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
|
|
450
470
|
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
451
471
|
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
452
472
|
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
@@ -516,11 +536,21 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
516
536
|
// rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
|
|
517
537
|
`if [ "$1" = "--resume" ]; then`,
|
|
518
538
|
` tid=$2`,
|
|
539
|
+
...(attachTui ? [] : [
|
|
540
|
+
// A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
|
|
541
|
+
// a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
|
|
542
|
+
`elif [ "$#" -eq 0 ]; then`,
|
|
543
|
+
` exit 0`,
|
|
544
|
+
]),
|
|
519
545
|
`else`,
|
|
520
|
-
` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@")
|
|
546
|
+
` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@")`,
|
|
547
|
+
` __spex_rc=$?`,
|
|
548
|
+
...(attachTui ? [` [ "$__spex_rc" -eq 0 ] || exit 1`] : [
|
|
549
|
+
` if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('codex-headless')}; exit "$__spex_rc"; fi`,
|
|
550
|
+
]),
|
|
521
551
|
`fi`,
|
|
522
552
|
`[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
|
|
523
|
-
`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"
|
|
553
|
+
...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
|
|
524
554
|
].join('\n')
|
|
525
555
|
return `bash -lc ${shQuote(script)} spexcode-codex`
|
|
526
556
|
}
|
|
@@ -1230,6 +1260,24 @@ export const codexHarness: Harness = {
|
|
|
1230
1260
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
1231
1261
|
}
|
|
1232
1262
|
|
|
1263
|
+
// Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
|
|
1264
|
+
// while launch only runs the backend-owned thread/start + first turn. There is no TUI to attach after that turn;
|
|
1265
|
+
// the shared project app-server keeps the thread addressable and idle sends use the inherited JSON-RPC channel.
|
|
1266
|
+
export const codexHeadlessHarness: Harness = {
|
|
1267
|
+
...codexHarness,
|
|
1268
|
+
id: 'codex-headless',
|
|
1269
|
+
headless: true,
|
|
1270
|
+
launchOneShot: true,
|
|
1271
|
+
messageStream: false,
|
|
1272
|
+
launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
|
|
1273
|
+
// Record-backed liveness is the family contract for sleeping headless threads. A broken app-server or missing
|
|
1274
|
+
// thread is surfaced by the inherited delivery call rather than converted into a speculative offline state.
|
|
1275
|
+
liveness: () => 'online',
|
|
1276
|
+
// There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
|
|
1277
|
+
// runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
|
|
1278
|
+
resumeArg: () => '',
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1233
1281
|
// @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
|
|
1234
1282
|
// the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
|
|
1235
1283
|
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
@@ -1367,7 +1415,7 @@ export const opencodeHeadlessHarness: Harness = {
|
|
|
1367
1415
|
}
|
|
1368
1416
|
|
|
1369
1417
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
1370
|
-
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness]
|
|
1418
|
+
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
|
|
1371
1419
|
|
|
1372
1420
|
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
1373
1421
|
export const defaultHarness: Harness = claudeHarness
|
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())
|