spexcode 0.4.3 → 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 +1 -1
- package/package.json +1 -1
- 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/guide.ts +18 -9
- package/spec-cli/src/harness.ts +120 -12
- 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/sessions.ts +23 -9
- package/spec-cli/templates/spexcode.json +7 -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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createConnection, createServer, type Server, type Socket } from 'node:net'
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
8
|
+
|
|
9
|
+
type ControlRequest = { type: 'deliver'; text: string }
|
|
10
|
+
type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
|
|
11
|
+
|
|
12
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
13
|
+
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
14
|
+
const CONTROL_TIMEOUT_MS = 30_000
|
|
15
|
+
const START_TIMEOUT_MS = 30_000
|
|
16
|
+
|
|
17
|
+
const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
18
|
+
|
|
19
|
+
/** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
|
|
20
|
+
export const piHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ph-${id}.sock`)
|
|
21
|
+
|
|
22
|
+
export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: string): string {
|
|
23
|
+
return [shQuote(SPEX), 'internal', 'pi-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(piCmd), '--'].join(' ')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
29
|
+
promise.then(
|
|
30
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
31
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let socket: Socket | undefined
|
|
39
|
+
let settled = false
|
|
40
|
+
let buffer = ''
|
|
41
|
+
const finish = (result: DispatchResult) => {
|
|
42
|
+
if (settled) return
|
|
43
|
+
settled = true
|
|
44
|
+
clearTimeout(timer)
|
|
45
|
+
socket?.destroy()
|
|
46
|
+
resolve(result)
|
|
47
|
+
}
|
|
48
|
+
const timer = setTimeout(() => finish({ ok: false, error: `pi-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
|
|
49
|
+
try { socket = createConnection(piHeadlessSock(id)) } catch (error) {
|
|
50
|
+
finish({ ok: false, error: `pi-headless controller connect failed for session ${id}: ${(error as Error).message}` })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
socket.setEncoding('utf8')
|
|
54
|
+
socket.on('connect', () => socket!.write(`${JSON.stringify(request)}\n`))
|
|
55
|
+
socket.on('data', (chunk) => {
|
|
56
|
+
buffer += chunk
|
|
57
|
+
const nl = buffer.indexOf('\n')
|
|
58
|
+
if (nl < 0) return
|
|
59
|
+
try {
|
|
60
|
+
const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
|
|
61
|
+
finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'pi-headless controller rejected the request' })
|
|
62
|
+
} catch (error) {
|
|
63
|
+
finish({ ok: false, error: `pi-headless returned an invalid control response: ${(error as Error).message}` })
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
socket.on('error', (error) => finish({ ok: false, error: `pi-headless controller unreachable for session ${id}: ${error.message}` }))
|
|
67
|
+
socket.on('close', () => finish({ ok: false, error: `pi-headless controller closed before confirming session ${id}` }))
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
|
|
72
|
+
controlRequest(rec.session, { type: 'deliver', text })
|
|
73
|
+
|
|
74
|
+
export class PiHeadlessController {
|
|
75
|
+
private server: Server | null = null
|
|
76
|
+
private child: ChildTurn | null = null
|
|
77
|
+
private controlQueue: Promise<void> = Promise.resolve()
|
|
78
|
+
private closing = false
|
|
79
|
+
private readonly socketPath: string
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly id: string,
|
|
83
|
+
_runtimeDir: string,
|
|
84
|
+
private readonly piCmd: string,
|
|
85
|
+
private readonly cwd = process.cwd(),
|
|
86
|
+
) {
|
|
87
|
+
this.socketPath = piHeadlessSock(id)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async start(initialPrompt?: string): Promise<void> {
|
|
91
|
+
try { rmSync(this.socketPath, { force: true }) } catch { /* stale control socket is replaced at startup */ }
|
|
92
|
+
this.server = createServer((socket) => this.accept(socket))
|
|
93
|
+
await new Promise<void>((resolve, reject) => {
|
|
94
|
+
const onError = (error: Error) => { this.server?.off('listening', onListening); reject(error) }
|
|
95
|
+
const onListening = () => { this.server?.off('error', onError); resolve() }
|
|
96
|
+
this.server!.once('error', onError)
|
|
97
|
+
this.server!.once('listening', onListening)
|
|
98
|
+
this.server!.listen(this.socketPath)
|
|
99
|
+
})
|
|
100
|
+
if (initialPrompt) void this.spawnTurn(initialPrompt, false).catch((error) => {
|
|
101
|
+
console.error(`[spex pi-headless] initial turn failed: ${(error as Error).message}`)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async close(): Promise<void> {
|
|
106
|
+
if (this.closing) return
|
|
107
|
+
this.closing = true
|
|
108
|
+
const child = this.child
|
|
109
|
+
if (child && child.process.exitCode === null) child.process.kill('SIGTERM')
|
|
110
|
+
await new Promise<void>((resolve) => {
|
|
111
|
+
if (!this.server) return resolve()
|
|
112
|
+
this.server.close(() => resolve())
|
|
113
|
+
})
|
|
114
|
+
try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
|
|
115
|
+
const { rvSock } = await import('./harness.js')
|
|
116
|
+
try { rmSync(rvSock(this.id), { force: true }) } catch { /* pi may already have removed it */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private accept(socket: Socket): void {
|
|
120
|
+
socket.setEncoding('utf8')
|
|
121
|
+
let buffer = ''
|
|
122
|
+
let handled = false
|
|
123
|
+
socket.on('data', (chunk) => {
|
|
124
|
+
if (handled) return
|
|
125
|
+
buffer += chunk
|
|
126
|
+
const nl = buffer.indexOf('\n')
|
|
127
|
+
if (nl < 0) return
|
|
128
|
+
handled = true
|
|
129
|
+
let request: ControlRequest
|
|
130
|
+
try { request = JSON.parse(buffer.slice(0, nl)) as ControlRequest } catch (error) {
|
|
131
|
+
socket.end(`${JSON.stringify({ ok: false, error: `invalid control request: ${(error as Error).message}` })}\n`)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
this.controlQueue = this.controlQueue.then(async () => {
|
|
135
|
+
const result = await this.handle(request).catch((error) => ({ ok: false, error: (error as Error).message }))
|
|
136
|
+
socket.end(`${JSON.stringify(result)}\n`)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async handle(request: ControlRequest): Promise<DispatchResult> {
|
|
142
|
+
if (request.type !== 'deliver') return { ok: false, error: 'unknown pi-headless control request' }
|
|
143
|
+
if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
|
|
144
|
+
|
|
145
|
+
// A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
|
|
146
|
+
// shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
|
|
147
|
+
const { deliverViaRendezvous, rendezvousListening } = await import('./harness.js')
|
|
148
|
+
const listener = await rendezvousListening(this.id)
|
|
149
|
+
if (listener === 'live') return deliverViaRendezvous(this.id, request.text)
|
|
150
|
+
if (listener === 'unproven') return { ok: false, error: `could not determine whether pi turn ${this.id} is live — prompt NOT delivered` }
|
|
151
|
+
|
|
152
|
+
if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
|
|
153
|
+
await this.spawnTurn(request.text, true)
|
|
154
|
+
return { ok: true }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async spawnTurn(text: string, resume: boolean): Promise<void> {
|
|
158
|
+
if (this.closing) throw new Error('pi-headless controller is closing')
|
|
159
|
+
const mode = resume ? ['--session', this.id] : ['--session-id', this.id]
|
|
160
|
+
// Keep pi's default text mode. `--mode json` is intentionally omitted: it can hang in this runtime.
|
|
161
|
+
const args = ['-p', ...mode, text]
|
|
162
|
+
const command = `exec ${this.piCmd} ${args.map(shQuote).join(' ')}`
|
|
163
|
+
const childProcess = spawn('/bin/sh', ['-lc', command], { cwd: this.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
164
|
+
let resolveExit!: (code: number | null) => void
|
|
165
|
+
const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
|
|
166
|
+
const turn: ChildTurn = { process: childProcess, exited }
|
|
167
|
+
this.child = turn
|
|
168
|
+
childProcess.stdout?.pipe(process.stdout)
|
|
169
|
+
childProcess.stderr?.pipe(process.stderr)
|
|
170
|
+
childProcess.once('error', (error) => console.error(`[spex pi-headless] child spawn failed: ${error.message}`))
|
|
171
|
+
childProcess.once('close', (code) => {
|
|
172
|
+
if (this.child === turn) this.child = null
|
|
173
|
+
resolveExit(code)
|
|
174
|
+
})
|
|
175
|
+
await withTimeout(new Promise<void>((resolve, reject) => {
|
|
176
|
+
childProcess.once('spawn', () => resolve())
|
|
177
|
+
childProcess.once('error', reject)
|
|
178
|
+
}), START_TIMEOUT_MS, `pi-headless child did not start for session ${this.id}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function runPiHeadlessController(id: string, runtimeDir: string, piCmd: string, tail: string[]): Promise<void> {
|
|
183
|
+
// runtimeDir is retained in the command shape for parity with claude-headless and future per-session output.
|
|
184
|
+
mkdirSync(join(runtimeDir, 'sessions', id), { recursive: true })
|
|
185
|
+
const controller = new PiHeadlessController(id, runtimeDir, piCmd)
|
|
186
|
+
const resume = tail[0] === '--session'
|
|
187
|
+
const prompt = resume ? undefined : tail[0] === '--session-id' ? tail.slice(2).join(' ') : tail.join(' ')
|
|
188
|
+
await controller.start(prompt)
|
|
189
|
+
await new Promise<void>((resolve) => {
|
|
190
|
+
const stop = () => void controller.close().finally(resolve)
|
|
191
|
+
process.once('SIGINT', stop)
|
|
192
|
+
process.once('SIGTERM', stop)
|
|
193
|
+
process.once('SIGHUP', stop)
|
|
194
|
+
})
|
|
195
|
+
}
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -117,6 +117,7 @@ export type Session = {
|
|
|
117
117
|
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
118
118
|
parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
|
|
119
119
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
120
|
+
capabilities: { headless: boolean; messageStream: boolean } // stable adapter projection; console surfaces consume data, never harness ids
|
|
120
121
|
launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
|
|
121
122
|
lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
|
|
122
123
|
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
@@ -565,13 +566,13 @@ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it
|
|
|
565
566
|
// for the grace window; only past it (still not online) is it genuinely 'offline'.
|
|
566
567
|
export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
567
568
|
if (!rec.session) return 'offline'
|
|
568
|
-
if (snap.probeFailed) return 'unknown' // the probe failed — we can't tell, and MUST NOT guess offline
|
|
569
569
|
// Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
|
|
570
570
|
// codex proves its launch-registered pid (with the legacy descendant-tree fallback). The 'starting' grace
|
|
571
571
|
// stays here: a just-launched agent whose online signal has not appeared yet reads 'starting', only past it
|
|
572
572
|
// 'offline'.
|
|
573
573
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
574
574
|
if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
|
|
575
|
+
if (snap.probeFailed) return 'unknown' // the probe failed — we can't tell, and MUST NOT guess offline
|
|
575
576
|
// not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
|
|
576
577
|
// off a full-but-alive backlog), death is UNPROVEN: `unknown`, never a false `offline` a supervisor would
|
|
577
578
|
// act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
|
|
@@ -611,7 +612,8 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
611
612
|
const act = showActivity ? activity : null
|
|
612
613
|
const pp = prompt ? promptPreview(prompt) : null
|
|
613
614
|
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
|
|
614
|
-
|
|
615
|
+
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
616
|
+
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless, messageStream: harness.messageStream }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
|
|
615
617
|
}
|
|
616
618
|
|
|
617
619
|
// @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
|
|
@@ -1341,9 +1343,12 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1341
1343
|
writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
|
|
1342
1344
|
await drainQueue() // launch now if under the cap, else leave it queued for a free slot
|
|
1343
1345
|
const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
|
|
1344
|
-
//
|
|
1346
|
+
// Every adapter answers from its own truth. Asking with no process facts distinguishes a record-backed
|
|
1347
|
+
// adapter (online immediately) from process-backed queued/booting adapters (offline/starting) without an
|
|
1348
|
+
// extra whole-box tmux snapshot on the creation hot path.
|
|
1349
|
+
const recordOnline = h.liveness(after, false, runtimeRoot()) === 'online'
|
|
1345
1350
|
const queued = after.status === 'queued'
|
|
1346
|
-
return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
|
|
1351
|
+
return toSession(after, queued ? 'queued' : 'working', recordOnline ? 'online' : queued ? 'offline' : 'starting')
|
|
1347
1352
|
}
|
|
1348
1353
|
|
|
1349
1354
|
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
|
|
@@ -1645,14 +1650,13 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
|
|
|
1645
1650
|
|
|
1646
1651
|
// @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
|
|
1647
1652
|
// two: kill the agent's tmux client, drop its boot-window stamp (else a just-launched id lingers in the grace
|
|
1648
|
-
// window reading `starting` instead of `offline`), and
|
|
1649
|
-
//
|
|
1650
|
-
// would accumulate stale `spexcode-rv-*.sock` files; we unlink it here (force = no error if claude/OS already
|
|
1651
|
-
// removed it). Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
|
|
1653
|
+
// window reading `starting` instead of `offline`), and ask the resolved adapter to sweep its ephemeral runtime
|
|
1654
|
+
// transport. Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
|
|
1652
1655
|
async function stopAgentProcess(id: string): Promise<void> {
|
|
1656
|
+
const rec = readRecord(id)
|
|
1653
1657
|
await tmuxOk(['kill-session', '-t', id])
|
|
1654
1658
|
launchedAt.delete(id)
|
|
1655
|
-
|
|
1659
|
+
harnessById(rec?.harness || defaultHarness.id).cleanupRuntime(rec ?? { session: id })
|
|
1656
1660
|
}
|
|
1657
1661
|
|
|
1658
1662
|
// @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
|
|
@@ -2008,6 +2012,16 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
2008
2012
|
return r
|
|
2009
2013
|
}
|
|
2010
2014
|
|
|
2015
|
+
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
|
|
2016
|
+
// confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
|
|
2017
|
+
export async function interruptSession(id: string): Promise<DispatchResult> {
|
|
2018
|
+
const rec = readRecord(id)
|
|
2019
|
+
if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
|
|
2020
|
+
const h = harnessById(rec.harness || defaultHarness.id)
|
|
2021
|
+
if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
|
|
2022
|
+
return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2011
2025
|
// @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
|
|
2012
2026
|
// socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
|
|
2013
2027
|
// agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
"lint": {
|
|
3
3
|
"governedRoots": ["."]
|
|
4
4
|
},
|
|
5
|
+
"dashboard": {
|
|
6
|
+
"showHeadlessLaunchers": false
|
|
7
|
+
},
|
|
5
8
|
"sessions": {
|
|
6
9
|
"launchers": {
|
|
7
10
|
"claude": { "harness": "claude", "cmd": "claude" },
|
|
11
|
+
"claude-headless": { "harness": "claude-headless", "cmd": "claude" },
|
|
8
12
|
"codex": { "harness": "codex", "cmd": "codex" },
|
|
9
13
|
"opencode": { "harness": "opencode", "cmd": "opencode" },
|
|
10
|
-
"
|
|
14
|
+
"opencode-headless": { "harness": "opencode-headless", "cmd": "opencode --auto" },
|
|
15
|
+
"pi": { "harness": "pi", "cmd": "pi" },
|
|
16
|
+
"pi-headless": { "harness": "pi-headless", "cmd": "pi" }
|
|
11
17
|
},
|
|
12
18
|
"defaultLauncher": "claude"
|
|
13
19
|
}
|