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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/src/claude-headless.ts +271 -0
  4. package/spec-cli/src/cli.ts +24 -1
  5. package/spec-cli/src/client.ts +8 -0
  6. package/spec-cli/src/guide.ts +18 -9
  7. package/spec-cli/src/harness.ts +120 -12
  8. package/spec-cli/src/help.ts +4 -1
  9. package/spec-cli/src/index.ts +19 -7
  10. package/spec-cli/src/layout.ts +1 -0
  11. package/spec-cli/src/message-stream.ts +147 -0
  12. package/spec-cli/src/opencode-headless.ts +95 -0
  13. package/spec-cli/src/pi-headless.ts +195 -0
  14. package/spec-cli/src/sessions.ts +23 -9
  15. package/spec-cli/templates/spexcode.json +7 -1
  16. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  17. package/spec-dashboard/dist/assets/{EvalsPage-DmiX3rdU.js → EvalsPage-5_nfIYll.js} +2 -2
  18. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  19. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  20. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  21. package/spec-dashboard/dist/assets/{PageScroll-C15adEYI.js → PageScroll-B_dKCuXx.js} +1 -1
  22. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  23. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  24. package/spec-dashboard/dist/assets/{SessionWindow-CuDO_67z.js → SessionWindow-BuJ5mzjC.js} +1 -1
  25. package/spec-dashboard/dist/assets/{Settings-C_N1wX1f.js → Settings-B8KFocsz.js} +1 -1
  26. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  27. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  28. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  29. package/spec-dashboard/dist/index.html +2 -2
  30. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +0 -27
  31. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +0 -1
  32. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +0 -2
  33. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +0 -1
  34. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +0 -1
  35. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +0 -39
  36. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +0 -41
  37. 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
+ }
@@ -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
- 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: rec.harness, 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
+ 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
- // queued no process yet (offline liveness); just-launched its socket is still booting (starting).
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 sweep its rendezvous socket. The socket lives in the OS
1649
- // tmpdir (NOT the worktree), so worktree removal alone would leave it behind closing many sessions over time
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
- try { rmSync(rvSock(id), { force: true }) } catch { /* best-effort sweep; tmpdir socket, claude/OS may already be gone */ }
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
- "pi": { "harness": "pi", "cmd": "pi" }
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
  }