spexcode 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/spec-cli/src/claude-headless.ts +271 -0
- package/spec-cli/src/cli.ts +24 -1
- package/spec-cli/src/client.ts +8 -0
- package/spec-cli/src/codex-headless.ts +13 -0
- package/spec-cli/src/guide.ts +18 -8
- package/spec-cli/src/harness.ts +150 -14
- package/spec-cli/src/help.ts +4 -1
- package/spec-cli/src/index.ts +19 -7
- package/spec-cli/src/layout.ts +1 -0
- package/spec-cli/src/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/pi-headless.ts +195 -0
- package/spec-cli/src/session-timeline.ts +6 -4
- package/spec-cli/src/sessions.ts +72 -39
- package/spec-cli/templates/spexcode.json +8 -1
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/{EvalsPage-DmiX3rdU.js → EvalsPage-5_nfIYll.js} +2 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-C15adEYI.js → PageScroll-B_dKCuXx.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CuDO_67z.js → SessionWindow-BuJ5mzjC.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-C_N1wX1f.js → Settings-B8KFocsz.js} +1 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +0 -27
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +0 -2
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +0 -39
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +0 -41
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +0 -1
|
@@ -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 (
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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`
|
|
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 } : {}) })
|
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
|
|
@@ -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
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
1996
|
-
|
|
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,
|
|
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
|
-
"
|
|
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
|
}
|