spexcode 0.4.1 → 0.5.0
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 +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/claude-headless.ts +271 -0
- package/spec-cli/src/cli.ts +112 -38
- package/spec-cli/src/client.ts +42 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +115 -56
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +295 -39
- package/spec-cli/src/help.ts +68 -28
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +158 -87
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +6 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/pi-headless.ts +195 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +211 -75
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +10 -2
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
- 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-B_dKCuXx.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.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/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export {} // make this a module so top-level await is allowed
|
|
2
2
|
// static import is fine here: mentions.ts is dependency-free at module level, and stripRefSigil is needed
|
|
3
|
-
// by several verbs (spec owner,
|
|
3
|
+
// by several verbs (spec owner, graph, issue/eval node args) — a CLI reference arg tolerates an optional @/[[ ]]
|
|
4
4
|
// sigil ([[mentions]]).
|
|
5
5
|
import { stripRefSigil } from './mentions.js'
|
|
6
6
|
|
|
@@ -60,6 +60,19 @@ function positionals(from: number): string[] {
|
|
|
60
60
|
return out
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function rejectUnknownFlags(command: string, from: number, allowed: readonly string[]): void {
|
|
64
|
+
const known = new Set(allowed.map((name) => `--${name}`))
|
|
65
|
+
for (let i = from; i < process.argv.length; i++) {
|
|
66
|
+
const token = process.argv[i]
|
|
67
|
+
if (!token.startsWith('--')) continue
|
|
68
|
+
if (!known.has(token)) {
|
|
69
|
+
console.error(`${command}: unknown flag ${token}`)
|
|
70
|
+
process.exit(2)
|
|
71
|
+
}
|
|
72
|
+
if (VALUE_FLAGS.has(token)) i++
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
// @@@ signposts (one version only — delete in 0.4.0) - every spelling v0.3.0 removed maps to its new home.
|
|
64
77
|
// A signpost REPORTS and exits 2; it never executes (not an alias): a stale hook or a human's muscle memory
|
|
65
78
|
// gets a readable failure that names the migration, and nothing old keeps silently working.
|
|
@@ -73,7 +86,6 @@ const SIGNPOSTS: Record<string, string> = {
|
|
|
73
86
|
blob: 'spex evidence put|get',
|
|
74
87
|
issues: 'spex issue — ls (was: bare issues) · show · open · reply · close · promote; on|off|status → the `issues.enabled` key in spexcode.json; `issues nudge` → spex internal nudge',
|
|
75
88
|
forge: 'spex issue links [--pending] [--store <host>] (--host is now --store)',
|
|
76
|
-
dashboard: 'spex serve ui',
|
|
77
89
|
new: 'spex session new',
|
|
78
90
|
ls: 'spex session ls',
|
|
79
91
|
watch: 'spex session watch',
|
|
@@ -173,7 +185,7 @@ async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: (
|
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
176
|
-
if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch)'); process.exit(2) }
|
|
188
|
+
if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
|
|
177
189
|
const { resolveClientSession } = await import('./client.js')
|
|
178
190
|
const { sessionLabel } = await import('./sessions.js')
|
|
179
191
|
const r = await resolveClientSession(selector)
|
|
@@ -309,6 +321,19 @@ if (cmd === 'serve') {
|
|
|
309
321
|
console.error(`spex serve: unknown target '${target}' — spex serve [api] (the backend) | spex serve ui (the dashboard)`)
|
|
310
322
|
process.exit(2)
|
|
311
323
|
}
|
|
324
|
+
} else if (cmd === 'dashboard') {
|
|
325
|
+
// the HOST-level dashboard ([[host-gateway]]): ONE gateway for every project this user serves. The
|
|
326
|
+
// engine is [[gateway-hub]] (routing + [[gateway-auth]] authorization: admin scope implicit from
|
|
327
|
+
// loopback until an admin password is set; per-project gates as configured); the host layer mounts the
|
|
328
|
+
// instance-validated project registry, its SSE stream, the durable catalog, and the /projects
|
|
329
|
+
// operations (register · init · doctor · start a backend) as the hub's admin extension. No --api-port
|
|
330
|
+
// pairing: which backend a request reaches is named in its /p/:projectId path, resolved per request.
|
|
331
|
+
// `spex serve ui` remains the explicit one-backend pairing; this verb is the zero-config many-project face.
|
|
332
|
+
const { startHostDashboard } = await import('./host.js')
|
|
333
|
+
const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
|
|
334
|
+
const host = flag('host') ?? '127.0.0.1'
|
|
335
|
+
if (!Number.isInteger(port)) { console.error('spex dashboard: --port must be an integer'); process.exit(2) }
|
|
336
|
+
startHostDashboard({ port, host })
|
|
312
337
|
} else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
313
338
|
// `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
|
|
314
339
|
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
@@ -467,9 +492,9 @@ if (cmd === 'serve') {
|
|
|
467
492
|
}
|
|
468
493
|
} else if (cmd === 'init') {
|
|
469
494
|
// scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
|
|
470
|
-
// into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>]
|
|
495
|
+
// into <targetDir> (default cwd). spex init [targetDir] --harness <ids> [--preset <tier>]
|
|
471
496
|
const { specInit } = await import('./init.js')
|
|
472
|
-
await specInit(positionals(3)[0], flag('preset'))
|
|
497
|
+
await specInit(positionals(3)[0], flag('preset'), flag('harness'))
|
|
473
498
|
} else if (cmd === 'uninstall') {
|
|
474
499
|
// the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
|
|
475
500
|
// .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.plugins data or their own
|
|
@@ -497,42 +522,35 @@ if (cmd === 'serve') {
|
|
|
497
522
|
if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
|
|
498
523
|
if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
|
|
499
524
|
const m = r.model
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
return { n, blind, rows }
|
|
510
|
-
})
|
|
511
|
-
const own = groups.reduce((a, g) => a + g.rows.filter((e) => e.inSession).length, 0)
|
|
512
|
-
console.log(`eval ${m.title} [${m.id}]`)
|
|
513
|
-
console.log(` branch : ${m.branch ?? '—'} · ${m.ahead} commit(s) ahead · ${m.dirtyNonRuntime} uncommitted`)
|
|
525
|
+
const byNode = new Map<string, any[]>()
|
|
526
|
+
for (const item of m.items) {
|
|
527
|
+
const rows = byNode.get(item.node) ?? []
|
|
528
|
+
rows.push(item)
|
|
529
|
+
byNode.set(item.node, rows)
|
|
530
|
+
}
|
|
531
|
+
const groups = [...byNode].map(([node, rows]) => ({ node, rows }))
|
|
532
|
+
const own = m.items.filter((item) => item.inSession).length
|
|
533
|
+
console.log(`eval session [${m.id}]`)
|
|
514
534
|
console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
515
535
|
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
|
|
516
|
-
if (!m.
|
|
517
|
-
for (const {
|
|
518
|
-
console.log(`\n${
|
|
519
|
-
for (const
|
|
536
|
+
if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
|
|
537
|
+
for (const { node, rows } of groups) {
|
|
538
|
+
console.log(`\n${node}`)
|
|
539
|
+
for (const item of rows.filter((row) => row.filterKind === 'blind')) console.log(` ∅ unmeasured ${item.scenario} — declared, never measured (blind spot)`)
|
|
520
540
|
let divided = false
|
|
521
|
-
for (const e of rows) {
|
|
522
|
-
if (!e.inSession && !divided && rows.some((x) => x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
|
|
541
|
+
for (const e of rows.filter((row) => row.filterKind === 'result')) {
|
|
542
|
+
if (!e.inSession && !divided && rows.some((x) => x.filterKind === 'result' && x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
|
|
523
543
|
const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
|
|
524
|
-
const stale = e.fresh ? '' : ` (stale: ${e.staleAxes.join(',')})`
|
|
544
|
+
const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
|
|
525
545
|
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
526
546
|
}
|
|
527
|
-
if (!n.hasEvalFile && !n.uncoveredFrontend) console.log(' (no eval.md — nothing declared to measure)')
|
|
528
|
-
else if (n.hasEvalFile && !n.scenarios.length) console.log(' (eval.md declares no scenarios)')
|
|
529
547
|
}
|
|
530
|
-
} else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
548
|
+
} else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
531
549
|
// node-scoped verbs — thin route; the logic lives in spec-eval.
|
|
532
550
|
const { runEval } = await import('../../spec-eval/src/cli.js')
|
|
533
551
|
await flushExit(await runEval(process.argv.slice(3)))
|
|
534
552
|
} else {
|
|
535
|
-
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | ok | retract | clean (spex help eval)`)
|
|
553
|
+
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | matrix | lint | ok | retract | clean (spex help eval)`)
|
|
536
554
|
if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
537
555
|
process.exit(2)
|
|
538
556
|
}
|
|
@@ -583,7 +601,14 @@ if (cmd === 'serve') {
|
|
|
583
601
|
// trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
|
|
584
602
|
// session-worktree creation, and the planted pre-commit/post-checkout/post-merge hooks.
|
|
585
603
|
const { materialize } = await import('./materialize.js')
|
|
586
|
-
|
|
604
|
+
try {
|
|
605
|
+
console.log(`materialized — content-hash ${materialize().contentHash}`)
|
|
606
|
+
} catch (e) {
|
|
607
|
+
// a policy error (e.g. a missing/illegal `harnesses` set) is a user-facing verdict, not a crash — one
|
|
608
|
+
// line + the repair it already carries, never a stack trace.
|
|
609
|
+
console.error(`spex materialize: ${(e as Error).message}`)
|
|
610
|
+
process.exit(1)
|
|
611
|
+
}
|
|
587
612
|
} else if (cmd === 'doctor') {
|
|
588
613
|
// @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
|
|
589
614
|
// the global install, while the report is about THIS agent's wiring): does the materialized workflow
|
|
@@ -599,12 +624,16 @@ if (cmd === 'serve') {
|
|
|
599
624
|
} else if (SESSION_SIGNPOSTS[sub]) {
|
|
600
625
|
signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
|
|
601
626
|
} else if (sub === 'new') {
|
|
602
|
-
// spex session new "<prompt>"
|
|
627
|
+
// spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
|
|
603
628
|
// <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
|
|
604
629
|
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
605
630
|
// it falls back to an in-process launch only when no backend answers.
|
|
631
|
+
if (has('node')) {
|
|
632
|
+
console.error('spex session new: --node was removed — put a [[<id>]] mention in the prompt — the first mention binds')
|
|
633
|
+
process.exit(2)
|
|
634
|
+
}
|
|
635
|
+
rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'api', 'port'])
|
|
606
636
|
const { createSession } = await import('./sessions.js')
|
|
607
|
-
if (has('harness')) { console.error('spex session new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
|
|
608
637
|
const promptFile = flag('prompt-file')
|
|
609
638
|
const inline = flag('prompt') ?? positionals(4)[0]
|
|
610
639
|
let prompt = inline ?? ''
|
|
@@ -616,8 +645,7 @@ if (cmd === 'serve') {
|
|
|
616
645
|
catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
|
|
617
646
|
if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
618
647
|
}
|
|
619
|
-
const
|
|
620
|
-
const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
|
|
648
|
+
const created = await createSession(prompt, flag('launcher') ?? undefined)
|
|
621
649
|
console.log(JSON.stringify(created, null, 2))
|
|
622
650
|
await launchMonitorReminder(created.id)
|
|
623
651
|
} else if (sub === 'ls') {
|
|
@@ -728,7 +756,7 @@ if (cmd === 'serve') {
|
|
|
728
756
|
// overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
|
|
729
757
|
const full = await resolveSelectorOrExit(id)
|
|
730
758
|
const r = await c.clientResume(full, process.argv.includes('--force'))
|
|
731
|
-
if (r.ok) console.log(`${full} -> resumed`)
|
|
759
|
+
if (r.ok) console.log(r.info ? `${full} -> ${r.info}` : `${full} -> resumed`)
|
|
732
760
|
else { console.error(`spex session resume: ${r.error || `no such session ${full}`}`); process.exit(2) }
|
|
733
761
|
} else if (sub === 'done') {
|
|
734
762
|
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
@@ -758,6 +786,11 @@ if (cmd === 'serve') {
|
|
|
758
786
|
// can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
|
|
759
787
|
const full = await resolveSelectorOrExit(id)
|
|
760
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)
|
|
761
794
|
} else if (sub === 'close') {
|
|
762
795
|
const full = await resolveSelectorOrExit(id)
|
|
763
796
|
console.log(await c.clientClose(full) ? `closed ${full}` : `no such session ${full}`)
|
|
@@ -840,7 +873,7 @@ if (cmd === 'serve') {
|
|
|
840
873
|
await assertLocalBackend()
|
|
841
874
|
await attachSession(await resolveSelectorOrExit(id))
|
|
842
875
|
} else {
|
|
843
|
-
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)`)
|
|
844
877
|
process.exit(2)
|
|
845
878
|
}
|
|
846
879
|
}
|
|
@@ -855,6 +888,18 @@ if (cmd === 'serve') {
|
|
|
855
888
|
// line; GET /api/settings exposes the same resolution (`.layout`).
|
|
856
889
|
const { mainBranch } = await import('./layout.js')
|
|
857
890
|
console.log(mainBranch())
|
|
891
|
+
} else if (sub === 'spec-governors') {
|
|
892
|
+
// Stable machine projection for spec-aware hooks: one real code: governor per row, with the live spec
|
|
893
|
+
// path the block reason can point at. Empty stdout means ungoverned (including related-only).
|
|
894
|
+
const file = process.argv[4]
|
|
895
|
+
if (!file) { console.error('usage: spex internal spec-governors <path>'); process.exit(2) }
|
|
896
|
+
const { specOwners, loadSpecsLite } = await import('./specs.js')
|
|
897
|
+
const paths = new Map(loadSpecsLite().map((node) => [node.id, node.path]))
|
|
898
|
+
for (const owner of specOwners(file)) {
|
|
899
|
+
const path = paths.get(owner.id)
|
|
900
|
+
if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
|
|
901
|
+
console.log(`${owner.id}\t${path}`)
|
|
902
|
+
}
|
|
858
903
|
} else if (sub === 'codex-launch') {
|
|
859
904
|
// BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
|
|
860
905
|
// (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
|
|
@@ -868,7 +913,7 @@ if (cmd === 'serve') {
|
|
|
868
913
|
// On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
|
|
869
914
|
// the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
|
|
870
915
|
// the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
|
|
871
|
-
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex
|
|
916
|
+
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex'))
|
|
872
917
|
const r = await codexStartThread(sock, cwd, bypassHookTrust)
|
|
873
918
|
if (!r.ok) { console.error(r.error); process.exit(1) }
|
|
874
919
|
if (prompt) {
|
|
@@ -887,6 +932,35 @@ if (cmd === 'serve') {
|
|
|
887
932
|
const sid = process.env.SPEXCODE_SESSION_ID
|
|
888
933
|
if (sid) markHarnessSessionId(sid, r.threadId)
|
|
889
934
|
console.log(r.threadId)
|
|
935
|
+
} else if (sub === 'opencode-capture') {
|
|
936
|
+
// opencode MINTS its own session id (no launch flag pins it), so the generated plugin's FIRST event calls
|
|
937
|
+
// this to store that id as harness_session_id on the governed record (SPEXCODE_SESSION_ID from the launch
|
|
938
|
+
// env, inherited by the opencode process → plugin). That is what lets reopen() resume the SAME
|
|
939
|
+
// conversation (`--session <id>`). A missing record/env is a clean no-op — a plugin loaded outside a
|
|
940
|
+
// governed launch has nothing to mark.
|
|
941
|
+
const { markHarnessSessionId } = await import('./sessions.js')
|
|
942
|
+
const ocid = process.argv[4]
|
|
943
|
+
if (!ocid) { console.error('usage: spex internal opencode-capture <opencode-session-id>'); process.exit(2) }
|
|
944
|
+
const sid = process.env.SPEXCODE_SESSION_ID
|
|
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))
|
|
890
964
|
} else if (sub === 'commit-surgery') {
|
|
891
965
|
// the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
|
|
892
966
|
// (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
2
|
-
import type { SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
3
2
|
|
|
4
3
|
export class BackendError extends Error {
|
|
5
4
|
constructor(message: string, readonly status?: number) {
|
|
@@ -74,15 +73,38 @@ export async function clientEvalExport(id: string): Promise<ExportResult> {
|
|
|
74
73
|
return { ok: false, status: r.status }
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
// The CLI's explicit aggregate walks the same 25-row pages as the dashboard. No server response contains
|
|
77
|
+
// the full session model; aggregation exists only for this one-shot terminal rendering.
|
|
78
|
+
type SessionEvalPage = {
|
|
79
|
+
items: any[]
|
|
80
|
+
page: number
|
|
81
|
+
pageCount: number
|
|
82
|
+
total: number
|
|
83
|
+
gates: any[]
|
|
84
|
+
unknown: number
|
|
85
|
+
revision: string
|
|
86
|
+
summary?: any
|
|
87
|
+
evalRevision?: any
|
|
88
|
+
}
|
|
89
|
+
export type EvalsResult = { ok: true; model: SessionEvalPage & { id: string } } | { ok: false; status: number }
|
|
82
90
|
export async function clientEvals(id: string): Promise<EvalsResult> {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
const q = encodeURIComponent(`is:eval scope:${id}`)
|
|
92
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
93
|
+
const items: any[] = []
|
|
94
|
+
let first: SessionEvalPage | null = null
|
|
95
|
+
let changed = false
|
|
96
|
+
for (let page = 1;; page++) {
|
|
97
|
+
const r = await apiFetch(`/api/evals?q=${q}&page=${page}`)
|
|
98
|
+
if (!r.ok) return { ok: false, status: r.status }
|
|
99
|
+
const current = await r.json() as SessionEvalPage
|
|
100
|
+
first ??= current
|
|
101
|
+
if (current.revision !== first.revision) { changed = true; break }
|
|
102
|
+
items.push(...current.items)
|
|
103
|
+
if (page >= current.pageCount) break
|
|
104
|
+
}
|
|
105
|
+
if (!changed) return { ok: true, model: { ...first!, id, items } }
|
|
106
|
+
}
|
|
107
|
+
throw new BackendError(`session eval pages changed while reading ${id}; retry the command`)
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
|
|
@@ -94,8 +116,9 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
|
|
|
94
116
|
|
|
95
117
|
// POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
|
|
96
118
|
// working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
|
|
97
|
-
// `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
|
|
98
|
-
|
|
119
|
+
// `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404). `info`
|
|
120
|
+
// carries a non-error advisory.
|
|
121
|
+
export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
99
122
|
await guarded('session resume')
|
|
100
123
|
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
|
|
101
124
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
|
|
@@ -109,6 +132,14 @@ export async function clientStop(id: string): Promise<boolean> {
|
|
|
109
132
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
110
133
|
}
|
|
111
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
|
+
|
|
112
143
|
// POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
|
|
113
144
|
export async function clientClose(id: string): Promise<boolean> {
|
|
114
145
|
await guarded('session close')
|