spexcode 0.4.3 → 0.5.1
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 +271 -0
- package/spec-cli/src/cli.ts +24 -1
- package/spec-cli/src/client.ts +8 -0
- package/spec-cli/src/codex-headless.ts +13 -0
- package/spec-cli/src/guide.ts +18 -8
- package/spec-cli/src/harness.ts +150 -14
- package/spec-cli/src/help.ts +4 -1
- package/spec-cli/src/index.ts +19 -7
- package/spec-cli/src/layout.ts +1 -0
- package/spec-cli/src/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/pi-headless.ts +195 -0
- package/spec-cli/src/session-timeline.ts +6 -4
- package/spec-cli/src/sessions.ts +72 -39
- package/spec-cli/templates/spexcode.json +8 -1
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/{EvalsPage-DmiX3rdU.js → EvalsPage-5_nfIYll.js} +2 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-C15adEYI.js → PageScroll-B_dKCuXx.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CuDO_67z.js → SessionWindow-BuJ5mzjC.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-C_N1wX1f.js → Settings-B8KFocsz.js} +1 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +0 -27
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +0 -2
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +0 -39
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +0 -41
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +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 # 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.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { createConnection, createServer, type Server, type Socket } from 'node:net'
|
|
3
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
9
|
+
|
|
10
|
+
type ControlRequest = { type: 'deliver'; text: string } | { type: 'interrupt' }
|
|
11
|
+
type ChildTurn = {
|
|
12
|
+
process: ChildProcessWithoutNullStreams
|
|
13
|
+
active: boolean
|
|
14
|
+
exited: Promise<number | null>
|
|
15
|
+
firstEvent: Promise<void>
|
|
16
|
+
sawFirstEvent: () => void
|
|
17
|
+
interruptAcks: Map<string, () => void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
21
|
+
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
22
|
+
const CONTROL_TIMEOUT_MS = 30_000
|
|
23
|
+
const START_TIMEOUT_MS = 30_000
|
|
24
|
+
const INTERRUPT_TIMEOUT_MS = 10_000
|
|
25
|
+
|
|
26
|
+
const shQuote = (s: string) => `'${s.replace(/'/g, `'\''`)}'`
|
|
27
|
+
const userEvent = (text: string) => JSON.stringify({
|
|
28
|
+
type: 'user',
|
|
29
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const claudeHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ch-${id}.sock`)
|
|
33
|
+
|
|
34
|
+
export function claudeHeadlessLaunchCommand(id: string, runtimeDir: string, claudeCmd: string): string {
|
|
35
|
+
return [shQuote(SPEX), 'internal', 'claude-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(claudeCmd), '--'].join(' ')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
41
|
+
promise.then(
|
|
42
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
43
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
44
|
+
)
|
|
45
|
+
})
|
|
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
|
+
|
|
82
|
+
export const interruptClaudeHeadless = (rec: HarnessDeliveryRecord) =>
|
|
83
|
+
controlRequest(rec.session, { type: 'interrupt' })
|
|
84
|
+
|
|
85
|
+
export class ClaudeHeadlessController {
|
|
86
|
+
private server: Server | null = null
|
|
87
|
+
private child: ChildTurn | null = null
|
|
88
|
+
private controlQueue: Promise<void> = Promise.resolve()
|
|
89
|
+
private closing = false
|
|
90
|
+
private readonly messagesPath: string
|
|
91
|
+
private readonly socketPath: string
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private readonly id: string,
|
|
95
|
+
runtimeDir: string,
|
|
96
|
+
private readonly claudeCmd: string,
|
|
97
|
+
private readonly cwd = process.cwd(),
|
|
98
|
+
) {
|
|
99
|
+
const dir = join(runtimeDir, 'sessions', id)
|
|
100
|
+
mkdirSync(dir, { recursive: true })
|
|
101
|
+
this.messagesPath = join(dir, 'messages.ndjson')
|
|
102
|
+
this.socketPath = claudeHeadlessSock(id)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async start(initialPrompt?: string): Promise<void> {
|
|
106
|
+
try { rmSync(this.socketPath, { force: true }) } catch { /* stale control socket is replaced at startup */ }
|
|
107
|
+
this.server = createServer((socket) => this.accept(socket))
|
|
108
|
+
await new Promise<void>((resolve, reject) => {
|
|
109
|
+
const onError = (error: Error) => { this.server?.off('listening', onListening); reject(error) }
|
|
110
|
+
const onListening = () => { this.server?.off('error', onError); resolve() }
|
|
111
|
+
this.server!.once('error', onError)
|
|
112
|
+
this.server!.once('listening', onListening)
|
|
113
|
+
this.server!.listen(this.socketPath)
|
|
114
|
+
})
|
|
115
|
+
if (initialPrompt) void this.spawnTurn(initialPrompt, false).catch((error) => {
|
|
116
|
+
console.error(`[spex claude-headless] initial turn failed: ${(error as Error).message}`)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async close(): Promise<void> {
|
|
121
|
+
if (this.closing) return
|
|
122
|
+
this.closing = true
|
|
123
|
+
const child = this.child
|
|
124
|
+
if (child && child.process.exitCode === null) child.process.kill('SIGTERM')
|
|
125
|
+
await new Promise<void>((resolve) => {
|
|
126
|
+
if (!this.server) return resolve()
|
|
127
|
+
this.server.close(() => resolve())
|
|
128
|
+
})
|
|
129
|
+
try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private accept(socket: Socket): void {
|
|
133
|
+
socket.setEncoding('utf8')
|
|
134
|
+
let buffer = ''
|
|
135
|
+
let handled = false
|
|
136
|
+
socket.on('data', (chunk) => {
|
|
137
|
+
if (handled) return
|
|
138
|
+
buffer += chunk
|
|
139
|
+
const nl = buffer.indexOf('\n')
|
|
140
|
+
if (nl < 0) return
|
|
141
|
+
handled = true
|
|
142
|
+
let request: ControlRequest
|
|
143
|
+
try {
|
|
144
|
+
request = JSON.parse(buffer.slice(0, nl)) as ControlRequest
|
|
145
|
+
} catch (error) {
|
|
146
|
+
socket.end(`${JSON.stringify({ ok: false, error: `invalid control request: ${(error as Error).message}` })}\n`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
this.controlQueue = this.controlQueue.then(async () => {
|
|
150
|
+
const result = await this.handle(request).catch((error) => ({ ok: false, error: (error as Error).message }))
|
|
151
|
+
socket.end(`${JSON.stringify(result)}\n`)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async handle(request: ControlRequest): Promise<DispatchResult> {
|
|
157
|
+
if (request.type === 'deliver') {
|
|
158
|
+
if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
|
|
159
|
+
const current = this.child
|
|
160
|
+
if (current?.active && current.process.stdin.writable) {
|
|
161
|
+
await this.writeLine(current, userEvent(request.text))
|
|
162
|
+
return { ok: true }
|
|
163
|
+
}
|
|
164
|
+
if (current) await withTimeout(current.exited, 5_000, 'previous claude-headless turn did not exit after its result')
|
|
165
|
+
await this.spawnTurn(request.text, true)
|
|
166
|
+
return { ok: true }
|
|
167
|
+
}
|
|
168
|
+
if (request.type === 'interrupt') return this.interrupt()
|
|
169
|
+
return { ok: false, error: 'unknown claude-headless control request' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async interrupt(): Promise<DispatchResult> {
|
|
173
|
+
const child = this.child
|
|
174
|
+
if (!child?.active || !child.process.stdin.writable) return { ok: true }
|
|
175
|
+
const requestId = randomUUID()
|
|
176
|
+
const ack = new Promise<void>((resolve) => child.interruptAcks.set(requestId, resolve))
|
|
177
|
+
await this.writeLine(child, JSON.stringify({
|
|
178
|
+
type: 'control_request',
|
|
179
|
+
request_id: requestId,
|
|
180
|
+
request: { subtype: 'interrupt' },
|
|
181
|
+
}))
|
|
182
|
+
try {
|
|
183
|
+
await withTimeout(ack, INTERRUPT_TIMEOUT_MS, `claude-headless interrupt was not confirmed for session ${this.id}`)
|
|
184
|
+
return { ok: true }
|
|
185
|
+
} finally {
|
|
186
|
+
child.interruptAcks.delete(requestId)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async spawnTurn(text: string, resume: boolean): Promise<void> {
|
|
191
|
+
if (this.closing) throw new Error('claude-headless controller is closing')
|
|
192
|
+
const mode = resume ? ['--resume', this.id] : ['--session-id', this.id]
|
|
193
|
+
const args = ['-p', ...mode, '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']
|
|
194
|
+
const command = `exec ${this.claudeCmd} ${args.map(shQuote).join(' ')}`
|
|
195
|
+
const childProcess = spawn('/bin/sh', ['-lc', command], { cwd: this.cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
196
|
+
let sawFirstEvent!: () => void
|
|
197
|
+
const firstEvent = new Promise<void>((resolve) => { sawFirstEvent = resolve })
|
|
198
|
+
let resolveExit!: (code: number | null) => void
|
|
199
|
+
const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
|
|
200
|
+
const turn: ChildTurn = { process: childProcess, active: true, exited, firstEvent, sawFirstEvent, interruptAcks: new Map() }
|
|
201
|
+
this.child = turn
|
|
202
|
+
let stdoutBuffer = ''
|
|
203
|
+
childProcess.stdout.setEncoding('utf8')
|
|
204
|
+
childProcess.stderr.pipe(process.stderr)
|
|
205
|
+
childProcess.stdout.on('data', (chunk) => {
|
|
206
|
+
stdoutBuffer += chunk
|
|
207
|
+
for (;;) {
|
|
208
|
+
const nl = stdoutBuffer.indexOf('\n')
|
|
209
|
+
if (nl < 0) break
|
|
210
|
+
const line = stdoutBuffer.slice(0, nl)
|
|
211
|
+
stdoutBuffer = stdoutBuffer.slice(nl + 1)
|
|
212
|
+
const nativeLine = `${line}\n`
|
|
213
|
+
appendFileSync(this.messagesPath, nativeLine)
|
|
214
|
+
globalThis.process.stdout.write(nativeLine)
|
|
215
|
+
turn.sawFirstEvent()
|
|
216
|
+
this.observeEvent(turn, line)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
childProcess.once('error', (error) => {
|
|
220
|
+
console.error(`[spex claude-headless] child spawn failed: ${error.message}`)
|
|
221
|
+
})
|
|
222
|
+
childProcess.once('close', (code) => {
|
|
223
|
+
turn.active = false
|
|
224
|
+
if (stdoutBuffer) console.error('[spex claude-headless] dropped a partial non-line stdout event')
|
|
225
|
+
if (this.child === turn) this.child = null
|
|
226
|
+
resolveExit(code)
|
|
227
|
+
})
|
|
228
|
+
await this.writeLine(turn, userEvent(text))
|
|
229
|
+
await Promise.race([
|
|
230
|
+
withTimeout(turn.firstEvent, START_TIMEOUT_MS, `claude-headless child produced no stream event for session ${this.id}`),
|
|
231
|
+
turn.exited.then((code) => { throw new Error(`claude-headless child exited before accepting the turn (code ${code ?? 'signal'})`) }),
|
|
232
|
+
])
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private observeEvent(turn: ChildTurn, line: string): void {
|
|
236
|
+
let event: any
|
|
237
|
+
try { event = JSON.parse(line) } catch { return }
|
|
238
|
+
if (event?.type === 'control_response' && typeof event?.response?.request_id === 'string') {
|
|
239
|
+
turn.interruptAcks.get(event.response.request_id)?.()
|
|
240
|
+
}
|
|
241
|
+
if (event?.type === 'result') {
|
|
242
|
+
turn.active = false
|
|
243
|
+
turn.process.stdin.end()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private writeLine(turn: ChildTurn, line: string): Promise<void> {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
if (!turn.process.stdin.writable) return reject(new Error('claude-headless child stdin is not writable'))
|
|
250
|
+
turn.process.stdin.write(`${line}\n`, (error) => error ? reject(error) : resolve())
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function runClaudeHeadlessController(
|
|
256
|
+
id: string,
|
|
257
|
+
runtimeDir: string,
|
|
258
|
+
claudeCmd: string,
|
|
259
|
+
tail: string[],
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
const controller = new ClaudeHeadlessController(id, runtimeDir, claudeCmd)
|
|
262
|
+
const resume = tail[0] === '--resume'
|
|
263
|
+
const prompt = resume ? undefined : tail[0] === '--session-id' ? tail.slice(2).join(' ') : tail.join(' ')
|
|
264
|
+
await controller.start(prompt)
|
|
265
|
+
await new Promise<void>((resolve) => {
|
|
266
|
+
const stop = () => void controller.close().finally(resolve)
|
|
267
|
+
process.once('SIGINT', stop)
|
|
268
|
+
process.once('SIGTERM', stop)
|
|
269
|
+
process.once('SIGHUP', stop)
|
|
270
|
+
})
|
|
271
|
+
}
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -786,6 +786,11 @@ if (cmd === 'serve') {
|
|
|
786
786
|
// can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
|
|
787
787
|
const full = await resolveSelectorOrExit(id)
|
|
788
788
|
console.log(await c.clientStop(full) ? `stopped ${full} (worktree kept — resumable)` : `no such session ${full}`)
|
|
789
|
+
} else if (sub === 'interrupt') {
|
|
790
|
+
const full = await resolveSelectorOrExit(id)
|
|
791
|
+
const r = await c.clientInterrupt(full)
|
|
792
|
+
console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`)
|
|
793
|
+
process.exit(r.ok ? 0 : 1)
|
|
789
794
|
} else if (sub === 'close') {
|
|
790
795
|
const full = await resolveSelectorOrExit(id)
|
|
791
796
|
console.log(await c.clientClose(full) ? `closed ${full}` : `no such session ${full}`)
|
|
@@ -868,7 +873,7 @@ if (cmd === 'serve') {
|
|
|
868
873
|
await assertLocalBackend()
|
|
869
874
|
await attachSession(await resolveSelectorOrExit(id))
|
|
870
875
|
} else {
|
|
871
|
-
console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
876
|
+
console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
872
877
|
process.exit(2)
|
|
873
878
|
}
|
|
874
879
|
}
|
|
@@ -938,6 +943,24 @@ if (cmd === 'serve') {
|
|
|
938
943
|
if (!ocid) { console.error('usage: spex internal opencode-capture <opencode-session-id>'); process.exit(2) }
|
|
939
944
|
const sid = process.env.SPEXCODE_SESSION_ID
|
|
940
945
|
console.log(sid && markHarnessSessionId(sid, ocid) ? `captured ${ocid}` : 'noop (no governed session record)')
|
|
946
|
+
} else if (sub === 'claude-headless-run') {
|
|
947
|
+
const id = process.argv[4], runtimeDir = process.argv[5], claudeCmd = process.argv[6]
|
|
948
|
+
const divider = process.argv[7]
|
|
949
|
+
if (!id || !runtimeDir || !claudeCmd || divider !== '--') {
|
|
950
|
+
console.error('usage: spex internal claude-headless-run <session-id> <runtime-dir> <claude-cmd> -- [--session-id <id> <prompt> | --resume <id>]')
|
|
951
|
+
process.exit(2)
|
|
952
|
+
}
|
|
953
|
+
const { runClaudeHeadlessController } = await import('./claude-headless.js')
|
|
954
|
+
await runClaudeHeadlessController(id, runtimeDir, claudeCmd, process.argv.slice(8))
|
|
955
|
+
} else if (sub === 'pi-headless-run') {
|
|
956
|
+
const id = process.argv[4], runtimeDir = process.argv[5], piCmd = process.argv[6]
|
|
957
|
+
const divider = process.argv[7]
|
|
958
|
+
if (!id || !runtimeDir || !piCmd || divider !== '--') {
|
|
959
|
+
console.error('usage: spex internal pi-headless-run <session-id> <runtime-dir> <pi-cmd> -- [--session-id <id> <prompt> | --session <id>]')
|
|
960
|
+
process.exit(2)
|
|
961
|
+
}
|
|
962
|
+
const { runPiHeadlessController } = await import('./pi-headless.js')
|
|
963
|
+
await runPiHeadlessController(id, runtimeDir, piCmd, process.argv.slice(8))
|
|
941
964
|
} else if (sub === 'commit-surgery') {
|
|
942
965
|
// the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
|
|
943
966
|
// (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -132,6 +132,14 @@ export async function clientStop(id: string): Promise<boolean> {
|
|
|
132
132
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// POST /api/sessions/:id/interrupt - native hard interrupt of the current turn. Unsupported harnesses and
|
|
136
|
+
// unreachable control planes return the backend's loud DispatchResult; no signal/raw-key fallback exists.
|
|
137
|
+
export async function clientInterrupt(id: string): Promise<DispatchResult> {
|
|
138
|
+
await guarded('session interrupt')
|
|
139
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/interrupt`, post({}))
|
|
140
|
+
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
|
|
141
|
+
}
|
|
142
|
+
|
|
135
143
|
// POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
|
|
136
144
|
export async function clientClose(id: string): Promise<boolean> {
|
|
137
145
|
await guarded('session close')
|
|
@@ -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 # 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).
|
|
@@ -259,14 +259,14 @@ settings verb — an agent CONFIGURES SpexCode by EDITING these files directly.
|
|
|
259
259
|
PORTABILITY, and picking the right one is the whole discipline:
|
|
260
260
|
|
|
261
261
|
spexcode.json COMMITTED — portable, shared by everyone on the repo. Layout, policy, dashboard
|
|
262
|
-
identity, lint policy, doctor health budgets, launcher NAMES. "Git is the database": tracked so the
|
|
262
|
+
identity and launcher visibility, lint policy, doctor health budgets, launcher NAMES. "Git is the database": tracked so the
|
|
263
263
|
team shares ONE configuration.
|
|
264
264
|
spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
|
|
265
265
|
paths. Layered OVER spexcode.json (see MERGE
|
|
266
266
|
below); a targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
|
|
267
267
|
|
|
268
268
|
Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
|
|
269
|
-
icon, lint policy, doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
|
|
269
|
+
icon or launcher-visibility policy, lint policy, doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
|
|
270
270
|
PATH of a launcher wrapper or a TLS cert path are machine facts → gitignored spexcode.local.json.
|
|
271
271
|
Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
|
|
272
272
|
\`spex session new\` or the dashboard without an explicit launcher choice.
|
|
@@ -296,8 +296,13 @@ Example — a repo whose trunk is \`staging\`, not \`main\`:
|
|
|
296
296
|
dashboard.apiUrl the per-project backend the dashboard proxies to (read frontend-side). For a SHARED
|
|
297
297
|
install prefer the API_URL env var; apiUrl here is the default only when the dashboard
|
|
298
298
|
lives inside the project.
|
|
299
|
+
dashboard.showHeadlessLaunchers
|
|
300
|
+
include launchers whose harness declares itself headless in the dashboard New Session
|
|
301
|
+
picker. Default: false. This changes dashboard visibility only; explicit CLI
|
|
302
|
+
--launcher selection can still use every configured launcher.
|
|
299
303
|
Example:
|
|
300
|
-
{ "dashboard": { "title": "MyApp specs", "icon": "mdi:rocket-launch"
|
|
304
|
+
{ "dashboard": { "title": "MyApp specs", "icon": "mdi:rocket-launch",
|
|
305
|
+
"showHeadlessLaunchers": false } }
|
|
301
306
|
|
|
302
307
|
── HOST GATEWAY ($SPEXCODE_HOME/config.json — per-user host identity, never a project file) ──
|
|
303
308
|
gateway.icon the global /projects icon, using the same preset ids above. Default: "gateway".
|
|
@@ -326,16 +331,21 @@ by name with --launcher/the dashboard dropdown, and the chosen name is persisted
|
|
|
326
331
|
reuses the same auth. There are NO magic built-ins: \`spex init\` SEEDS an ordinary named launcher for each
|
|
327
332
|
harness the adopter SELECTED (--harness), from the template pool
|
|
328
333
|
"claude" → { "harness": "claude", "cmd": "claude" }
|
|
334
|
+
"claude-headless" → { "harness": "claude-headless", "cmd": "claude" }
|
|
329
335
|
"codex" → { "harness": "codex", "cmd": "codex" }
|
|
336
|
+
"codex-headless" → { "harness": "codex-headless", "cmd": "codex --yolo" }
|
|
330
337
|
"opencode" → { "harness": "opencode", "cmd": "opencode" }
|
|
338
|
+
"opencode-headless" → { "harness": "opencode-headless", "cmd": "opencode --auto" }
|
|
331
339
|
"pi" → { "harness": "pi", "cmd": "pi" }
|
|
332
|
-
|
|
333
|
-
|
|
340
|
+
"pi-headless" → { "harness": "pi-headless", "cmd": "pi" }
|
|
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
|
|
334
344
|
defaults: define and select one explicitly only when that access is intended. To run workers under an auth
|
|
335
345
|
wrapper (e.g. reclaude), point a launcher's \`cmd\` at it in spexcode.local.json — there is no environment
|
|
336
346
|
override that rewrites a launcher's command. Add more profiles when a project needs named auth/config-dir or
|
|
337
347
|
permission variants. Shape:
|
|
338
|
-
"launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi",
|
|
348
|
+
"launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "codex-headless" | "opencode-headless" | "pi-headless",
|
|
339
349
|
"cmd": "<launch command>" } }
|
|
340
350
|
\`harness\` defaults to "claude"; \`cmd\` is required and embedded whole. A portable plain command may live
|
|
341
351
|
in committed spexcode.json (as the init seeds do). A host-specific command — an absolute wrapper path,
|
|
@@ -444,7 +454,7 @@ Example — tune opt-in health diagnosis without changing the lint gate:
|
|
|
444
454
|
preset the SELECTED init preset — which cumulative .plugins tier \`spex init\` seeds (default
|
|
445
455
|
'default'; seed-time only, read by init.ts).
|
|
446
456
|
harnesses which harness targets \`spex materialize\` delivers into — native ids
|
|
447
|
-
("claude"|"codex"|"opencode"|"pi") or a
|
|
457
|
+
("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"codex-headless"|"opencode-headless"|"pi-headless") or a
|
|
448
458
|
{ "plugin": "<folder>" } bundle. REQUIRED — there is no default set: \`spex init --harness\`
|
|
449
459
|
stamps the explicit choice, and a missing field fails materialize loud. PERSISTENT and
|
|
450
460
|
git-transactional: the edit takes effect at the next git-native materialize anchor (the commit
|