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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  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/codex-headless.ts +13 -0
  7. package/spec-cli/src/guide.ts +18 -8
  8. package/spec-cli/src/harness.ts +150 -14
  9. package/spec-cli/src/help.ts +4 -1
  10. package/spec-cli/src/index.ts +19 -7
  11. package/spec-cli/src/layout.ts +1 -0
  12. package/spec-cli/src/message-stream.ts +147 -0
  13. package/spec-cli/src/opencode-headless.ts +95 -0
  14. package/spec-cli/src/pi-headless.ts +195 -0
  15. package/spec-cli/src/session-timeline.ts +6 -4
  16. package/spec-cli/src/sessions.ts +72 -39
  17. package/spec-cli/templates/spexcode.json +8 -1
  18. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  19. package/spec-dashboard/dist/assets/{EvalsPage-DmiX3rdU.js → EvalsPage-5_nfIYll.js} +2 -2
  20. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  21. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  22. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  23. package/spec-dashboard/dist/assets/{PageScroll-C15adEYI.js → PageScroll-B_dKCuXx.js} +1 -1
  24. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  25. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  26. package/spec-dashboard/dist/assets/{SessionWindow-CuDO_67z.js → SessionWindow-BuJ5mzjC.js} +1 -1
  27. package/spec-dashboard/dist/assets/{Settings-C_N1wX1f.js → Settings-B8KFocsz.js} +1 -1
  28. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  29. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  30. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  31. package/spec-dashboard/dist/index.html +2 -2
  32. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +0 -27
  33. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +0 -1
  34. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +0 -2
  35. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +0 -1
  36. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +0 -1
  37. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +0 -39
  38. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +0 -41
  39. 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
+ }
@@ -22,9 +22,10 @@ import type { Lifecycle, Proposal } from './sessions.js'
22
22
  // is recorded — liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived
23
23
  // per probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from
24
24
  // the board row. The timeline lives and dies with the session record (close sweeps the store dir), like
25
- // comms.ndjson. `sent` events are appended by sendText on a CONFIRMED delivery (all prompt deliveries flow
26
- // through it: dashboard/phone input, `spex session send`, the merge dispatch); `from` is the sending
27
- // session's id, null = a human surface.
25
+ // comms.ndjson. `sent` events are appended by sendText on a CONFIRMED post-launch delivery (dashboard/phone
26
+ // input, `spex session send`, merge and issue dispatch); the initial launch prompt passes through the same
27
+ // composition seam but has no adapter confirmation to record here. `from` is the sending session's id,
28
+ // null = a human surface.
28
29
 
29
30
  export type TimelineEvent =
30
31
  | { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
@@ -134,7 +135,8 @@ export function lastHumanSendVia(id: string): 'note' | null {
134
135
 
135
136
  // record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
136
137
  // caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
137
- // `replyVia` marks that the hint rode along so a surface can badge it.
138
+ // `replyVia` is the effective channel chosen by the shared prompt seam, whether explicit or derived from the
139
+ // target adapter, so the durable history records where the reply was actually readable.
138
140
  export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
139
141
  try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
140
142
  append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
@@ -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
@@ -890,12 +892,12 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
890
892
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
891
893
  return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
892
894
  }
893
- // @@@ withNoteReplyHint - the TERMINAL-FREE sender's insert, withSenderHint's sibling: a phone (or any
894
- // no-terminal surface, [[mobile-ui]]) cannot read the pane, so the only text that ever reaches its human is
895
- // the declaration NOTE ([[session-timeline]]). This one-line insert tells the agent exactly that, so its
896
- // next stop carries the complete answer in `--note` instead of prose that dies in an unseen terminal.
897
- // Appended server-side (the input route passes replyVia:'note'), so the phrase lives in ONE place and any
898
- // surface — desktop included, later — can opt in with the same flag. The notice declares itself
895
+ // @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
896
+ // terminal can return text to its human only through its declaration NOTE ([[session-timeline]]). This
897
+ // one-line insert tells the agent exactly that, so its next stop carries the complete answer in `--note`
898
+ // instead of prose that dies in an unseen output stream. composeSessionPrompt is the only production caller
899
+ // deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
900
+ // default. The notice declares itself
899
901
  // PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
900
902
  // note-replied a few times keeps note-replying from context inertia long after the human is back at a
901
903
  // terminal — the sticky-note failure this pair exists to prevent.
@@ -994,6 +996,30 @@ export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpe
994
996
  const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
995
997
  return composeCommandPrompt(raw, [preset], specs)
996
998
  }
999
+
1000
+ type SessionPromptTarget = Pick<SessRec, 'session' | 'harness'>
1001
+ type SessionPromptOptions = {
1002
+ from?: string
1003
+ replyVia?: 'note'
1004
+ loadedSpecs?: CommandSpec[]
1005
+ suffix?: string
1006
+ }
1007
+ export type ComposedSessionPrompt = { text: string; replyVia?: 'note' }
1008
+
1009
+ // @@@ composeSessionPrompt - the ONE prompt-delivery seam: raw caller text + target session become the
1010
+ // exact text handed to an adapter. Launch, ordinary input, CLI send, issue dispatch, watch greetings, and
1011
+ // merge all enter here (directly or through sendText). `replyVia` is target readability: an explicit note
1012
+ // request wins; otherwise a headless adapter defaults to note. This function alone decides and appends the
1013
+ // note/terminal inserts, so clients never own the policy or duplicate the phrase.
1014
+ export async function composeSessionPrompt(raw: string, target: SessionPromptTarget, opts: SessionPromptOptions = {}): Promise<ComposedSessionPrompt> {
1015
+ const resolved = await resolveCommandPrompt(raw, opts.loadedSpecs)
1016
+ const prompt = opts.suffix ? `${resolved}${opts.suffix}` : resolved
1017
+ const h = harnessById(target.harness || defaultHarness.id)
1018
+ const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
1019
+ const text = replyVia === 'note' ? withNoteReplyHint(prompt)
1020
+ : !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
1021
+ return { text, ...(replyVia ? { replyVia } : {}) }
1022
+ }
997
1023
  // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
998
1024
  // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
999
1025
  // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
@@ -1055,7 +1081,10 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1055
1081
  // retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
1056
1082
  // only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
1057
1083
  // attempts, then give up).
1058
- writeFileSync(file, [
1084
+ // A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
1085
+ // app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
1086
+ // retry loop is a runtime capability rather than a harness-id branch.
1087
+ const launchBody = harness.launchOneShot ? [born, ''] : [
1059
1088
  `for __spex_try in 1 2 3; do`,
1060
1089
  ` __spex_t0=$SECONDS`,
1061
1090
  ` ${born}`,
@@ -1066,7 +1095,8 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1066
1095
  `done`,
1067
1096
  `exit $__spex_rc`,
1068
1097
  ``,
1069
- ].join('\n'))
1098
+ ]
1099
+ writeFileSync(file, launchBody.join('\n'))
1070
1100
  return file
1071
1101
  }
1072
1102
  async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
@@ -1284,19 +1314,25 @@ export async function newSession(prompt: string, parent: string | null = null, l
1284
1314
  const chosen = resolveLauncher(lname)
1285
1315
  const h = harnessById(chosen.harness)
1286
1316
  const pinned = h.baseCmd(chosen.cmd)
1287
- // Resolve a command preset at the shared backend prompt boundary, before any worktree exists. The RAW prompt remains the
1288
- // identity + originating-prompt source; only `launchPrompt` is expanded for the agent. This preserves the
1289
- // no-target rule even when the plugin body itself contains `[[links]]`.
1290
1317
  const rawPrompt = prompt
1291
1318
  // node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
1292
1319
  // plugin prose is payload only and can never invent scope.
1293
1320
  const ref = nodeFromPrompt(rawPrompt)
1294
1321
  const launchSpecs = ref ? await loadSpecs() : null
1295
- let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
1296
1322
  const title = ref ? null : titleFromPrompt(rawPrompt)
1297
1323
  const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1298
1324
  const branch = `node/${slug}`
1299
1325
  const path = join(mainRoot(), '.worktrees', slug)
1326
+ // Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
1327
+ // preset resolution breaks. The optional spec pointer is a seam input; the note insert remains last.
1328
+ const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
1329
+ const suffix = spec
1330
+ ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1331
+ : undefined
1332
+ const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
1333
+ loadedSpecs: launchSpecs ?? undefined,
1334
+ suffix,
1335
+ })).text
1300
1336
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1301
1337
  // the checkout delivers the tracked spec sources and the materialize below delivers the materialized
1302
1338
  // artifacts; the ONE
@@ -1329,21 +1365,15 @@ export async function newSession(prompt: string, parent: string | null = null, l
1329
1365
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1330
1366
  // own memory load too.
1331
1367
  bootstrapMaterialize(rec)
1332
- if (ref) {
1333
- // @@@ spec pointer - the prompt's first [[id]] ref named an EXISTING node.
1334
- // Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
1335
- // it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
1336
- // is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
1337
- // a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
1338
- const spec = launchSpecs?.find((n) => n.id === ref)
1339
- if (spec) launchPrompt = `${launchPrompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1340
- }
1341
1368
  writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
1342
1369
  await drainQueue() // launch now if under the cap, else leave it queued for a free slot
1343
1370
  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).
1371
+ // Every adapter answers from its own truth. Asking with no process facts distinguishes a record-backed
1372
+ // adapter (online immediately) from process-backed queued/booting adapters (offline/starting) without an
1373
+ // extra whole-box tmux snapshot on the creation hot path.
1374
+ const recordOnline = h.liveness(after, false, runtimeRoot()) === 'online'
1345
1375
  const queued = after.status === 'queued'
1346
- return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
1376
+ return toSession(after, queued ? 'queued' : 'working', recordOnline ? 'online' : queued ? 'offline' : 'starting')
1347
1377
  }
1348
1378
 
1349
1379
  // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
@@ -1645,14 +1675,13 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
1645
1675
 
1646
1676
  // @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
1647
1677
  // 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.
1678
+ // window reading `starting` instead of `offline`), and ask the resolved adapter to sweep its ephemeral runtime
1679
+ // transport. Deliberately does NOT drainQueue the caller drains once, after it has settled the worktree.
1652
1680
  async function stopAgentProcess(id: string): Promise<void> {
1681
+ const rec = readRecord(id)
1653
1682
  await tmuxOk(['kill-session', '-t', id])
1654
1683
  launchedAt.delete(id)
1655
- try { rmSync(rvSock(id), { force: true }) } catch { /* best-effort sweep; tmpdir socket, claude/OS may already be gone */ }
1684
+ harnessById(rec?.harness || defaultHarness.id).cleanupRuntime(rec ?? { session: id })
1656
1685
  }
1657
1686
 
1658
1687
  // @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
@@ -1992,22 +2021,26 @@ export async function sendText(id: string, text: string, from?: string, opts: {
1992
2021
  if (blocked) return { ok: false, error: blocked }
1993
2022
  } catch { /* no pane to consult — let the delivery channel decide */ }
1994
2023
  }
1995
- const prompt = await resolveCommandPrompt(text)
1996
- // a terminal-free sender's dispatch carries the note-reply insert; a human send WITHOUT the flag whose
1997
- // previous human send carried it is the note→terminal transition and gets the one-shot counter-insert
1998
- // ([[session-timeline]]). Both appended here, beside the delivery, so every input surface shares the one
1999
- // phrase pair and the timeline records the message WITHOUT it (the hint is transport, not conversation).
2000
- const wrapped = opts.replyVia === 'note' ? withNoteReplyHint(prompt)
2001
- : !from && lastHumanSendVia(id) === 'note' ? withTerminalReplyHint(prompt) : prompt
2002
- const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, wrapped)
2024
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
2025
+ const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, prompt.text)
2003
2026
  // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
2004
2027
  // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
2005
2028
  if (r.ok && from) void recordComms(id, from)
2006
2029
  // the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
2007
- if (r.ok) recordSent(id, text, from ?? null, opts.replyVia)
2030
+ if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
2008
2031
  return r
2009
2032
  }
2010
2033
 
2034
+ // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
2035
+ // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
2036
+ export async function interruptSession(id: string): Promise<DispatchResult> {
2037
+ const rec = readRecord(id)
2038
+ if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
2039
+ const h = harnessById(rec.harness || defaultHarness.id)
2040
+ if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
2041
+ return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
2042
+ }
2043
+
2011
2044
  // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
2012
2045
  // socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
2013
2046
  // agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
@@ -2,12 +2,19 @@
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" },
13
+ "codex-headless": { "harness": "codex-headless", "cmd": "codex --yolo" },
9
14
  "opencode": { "harness": "opencode", "cmd": "opencode" },
10
- "pi": { "harness": "pi", "cmd": "pi" }
15
+ "opencode-headless": { "harness": "opencode-headless", "cmd": "opencode --auto" },
16
+ "pi": { "harness": "pi", "cmd": "pi" },
17
+ "pi-headless": { "harness": "pi-headless", "cmd": "pi" }
11
18
  },
12
19
  "defaultLauncher": "claude"
13
20
  }