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
package/spec-cli/src/harness.ts
CHANGED
|
@@ -9,6 +9,10 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
9
|
import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands, type SlashCommand } from './slash-commands.js'
|
|
10
10
|
import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
|
|
11
11
|
import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
|
|
12
|
+
import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js'
|
|
13
|
+
import { codexHeadlessLaunchCommand } from './codex-headless.js'
|
|
14
|
+
import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
|
|
15
|
+
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
|
|
12
16
|
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
13
17
|
import { git } from './git.js'
|
|
14
18
|
|
|
@@ -24,7 +28,7 @@ import { git } from './git.js'
|
|
|
24
28
|
// payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
|
|
25
29
|
// (materialize writes every harness's artifacts).
|
|
26
30
|
|
|
27
|
-
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi'
|
|
31
|
+
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
28
32
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
|
|
29
33
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
30
34
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
@@ -41,6 +45,15 @@ export type PaneProbe = { panePid?: number; procs?: ProcTable; pidAlive?: boolea
|
|
|
41
45
|
|
|
42
46
|
export interface Harness {
|
|
43
47
|
readonly id: HarnessId
|
|
48
|
+
// whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
|
|
49
|
+
// adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
|
|
50
|
+
readonly headless: boolean
|
|
51
|
+
// whether the launch command intentionally exits after its first turn instead of owning a resident process.
|
|
52
|
+
// One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
|
|
53
|
+
readonly launchOneShot?: boolean
|
|
54
|
+
// whether this harness persists a native event stream that the console may expose as an optional
|
|
55
|
+
// full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
|
|
56
|
+
readonly messageStream: boolean
|
|
44
57
|
// the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
|
|
45
58
|
// Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
|
|
46
59
|
// idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
|
|
@@ -144,6 +157,13 @@ export interface Harness {
|
|
|
144
157
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
145
158
|
// Returns ok=false with a reason that propagates to the API.
|
|
146
159
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
160
|
+
// Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
|
|
161
|
+
// without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
|
|
162
|
+
interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
|
|
163
|
+
// Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
|
|
164
|
+
// launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
|
|
165
|
+
// per-session socket. Product teardown calls only this adapter method.
|
|
166
|
+
cleanupRuntime(rec: HarnessLivenessRecord): void
|
|
147
167
|
// the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
|
|
148
168
|
// socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
|
|
149
169
|
// the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
|
|
@@ -178,7 +198,13 @@ export interface Harness {
|
|
|
178
198
|
// (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
179
199
|
// re-exports it for its existing importers.
|
|
180
200
|
export type DispatchResult = { ok: boolean; error?: string }
|
|
181
|
-
export type HarnessDeliveryRecord = {
|
|
201
|
+
export type HarnessDeliveryRecord = {
|
|
202
|
+
session: string
|
|
203
|
+
worktreePath?: string
|
|
204
|
+
harnessSessionId?: string | null
|
|
205
|
+
runtimeDir?: string
|
|
206
|
+
launchCmd?: string | null
|
|
207
|
+
}
|
|
182
208
|
// the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
|
|
183
209
|
// skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
|
|
184
210
|
// also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
|
|
@@ -424,7 +450,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
|
|
|
424
450
|
bypassProbe.set(binary, ok)
|
|
425
451
|
return ok
|
|
426
452
|
}
|
|
427
|
-
export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
|
|
453
|
+
export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
|
|
428
454
|
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
429
455
|
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
430
456
|
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
@@ -494,11 +520,17 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
494
520
|
// rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
|
|
495
521
|
`if [ "$1" = "--resume" ]; then`,
|
|
496
522
|
` tid=$2`,
|
|
523
|
+
...(attachTui ? [] : [
|
|
524
|
+
// A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
|
|
525
|
+
// a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
|
|
526
|
+
`elif [ "$#" -eq 0 ]; then`,
|
|
527
|
+
` exit 0`,
|
|
528
|
+
]),
|
|
497
529
|
`else`,
|
|
498
530
|
` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@") || exit 1`,
|
|
499
531
|
`fi`,
|
|
500
532
|
`[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
|
|
501
|
-
`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"
|
|
533
|
+
...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
|
|
502
534
|
].join('\n')
|
|
503
535
|
return `bash -lc ${shQuote(script)} spexcode-codex`
|
|
504
536
|
}
|
|
@@ -1079,6 +1111,8 @@ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
|
|
|
1079
1111
|
|
|
1080
1112
|
export const claudeHarness: Harness = {
|
|
1081
1113
|
id: 'claude',
|
|
1114
|
+
headless: false,
|
|
1115
|
+
messageStream: false,
|
|
1082
1116
|
events: CLAUDE_EVENTS,
|
|
1083
1117
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
1084
1118
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
@@ -1101,6 +1135,7 @@ export const claudeHarness: Harness = {
|
|
|
1101
1135
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
1102
1136
|
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
1103
1137
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1138
|
+
cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
|
|
1104
1139
|
// the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
|
|
1105
1140
|
// and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
|
|
1106
1141
|
// the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
|
|
@@ -1113,8 +1148,29 @@ export const claudeHarness: Harness = {
|
|
|
1113
1148
|
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
1114
1149
|
}
|
|
1115
1150
|
|
|
1151
|
+
// Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
|
|
1152
|
+
// reused by object composition; the whole runtime half is replaced by the stream-json controller.
|
|
1153
|
+
export const claudeHeadlessHarness: Harness = {
|
|
1154
|
+
...claudeHarness,
|
|
1155
|
+
id: 'claude-headless',
|
|
1156
|
+
headless: true,
|
|
1157
|
+
messageStream: true,
|
|
1158
|
+
ownsRendezvous: false,
|
|
1159
|
+
paneTitleIsSelfSummary: false,
|
|
1160
|
+
launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
|
|
1161
|
+
// Liveness is the intact record's property. A missing controller/child fails loudly at control time rather
|
|
1162
|
+
// than turning an idle (no child) session into a speculative offline row.
|
|
1163
|
+
liveness: () => 'online',
|
|
1164
|
+
deliver: deliverViaClaudeHeadless,
|
|
1165
|
+
interrupt: interruptClaudeHeadless,
|
|
1166
|
+
cleanupRuntime: (rec) => { try { rmSync(claudeHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ } },
|
|
1167
|
+
deliveryBlockedBy: undefined,
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1116
1170
|
export const codexHarness: Harness = {
|
|
1117
1171
|
id: 'codex',
|
|
1172
|
+
headless: false,
|
|
1173
|
+
messageStream: false,
|
|
1118
1174
|
events: CODEX_EVENTS,
|
|
1119
1175
|
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
1120
1176
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
@@ -1177,12 +1233,31 @@ export const codexHarness: Harness = {
|
|
|
1177
1233
|
return paneTreeRunsCodex(pane) ? 'online' : 'offline'
|
|
1178
1234
|
},
|
|
1179
1235
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
1236
|
+
cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
1180
1237
|
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
1181
1238
|
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
1182
1239
|
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
1183
1240
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
1184
1241
|
}
|
|
1185
1242
|
|
|
1243
|
+
// Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
|
|
1244
|
+
// while launch only runs the backend-owned thread/start + first turn. There is no TUI to attach after that turn;
|
|
1245
|
+
// the shared project app-server keeps the thread addressable and idle sends use the inherited JSON-RPC channel.
|
|
1246
|
+
export const codexHeadlessHarness: Harness = {
|
|
1247
|
+
...codexHarness,
|
|
1248
|
+
id: 'codex-headless',
|
|
1249
|
+
headless: true,
|
|
1250
|
+
launchOneShot: true,
|
|
1251
|
+
messageStream: false,
|
|
1252
|
+
launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
|
|
1253
|
+
// Record-backed liveness is the family contract for sleeping headless threads. A broken app-server or missing
|
|
1254
|
+
// thread is surfaced by the inherited delivery call rather than converted into a speculative offline state.
|
|
1255
|
+
liveness: () => 'online',
|
|
1256
|
+
// There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
|
|
1257
|
+
// runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
|
|
1258
|
+
resumeArg: () => '',
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1186
1261
|
// @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
|
|
1187
1262
|
// the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
|
|
1188
1263
|
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
@@ -1196,6 +1271,8 @@ export const codexHarness: Harness = {
|
|
|
1196
1271
|
// one-run defence. See pi-harness.ts for the extension source + trust mechanics.
|
|
1197
1272
|
export const piHarness: Harness = {
|
|
1198
1273
|
id: 'pi',
|
|
1274
|
+
headless: false,
|
|
1275
|
+
messageStream: false,
|
|
1199
1276
|
events: PI_EVENTS,
|
|
1200
1277
|
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
1201
1278
|
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
@@ -1220,13 +1297,37 @@ export const piHarness: Harness = {
|
|
|
1220
1297
|
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
1221
1298
|
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
1222
1299
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1300
|
+
cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
|
|
1223
1301
|
// reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
|
|
1224
1302
|
// LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
|
|
1225
1303
|
resumeArg: (rec) => `--session ${rec.session}`,
|
|
1226
1304
|
}
|
|
1227
1305
|
|
|
1306
|
+
// pi-headless is an independent harness: its materialization surface is literally pi's, while a resident
|
|
1307
|
+
// controller owns non-interactive text-mode turns. Active turns steer through pi's rendezvous extension;
|
|
1308
|
+
// idle delivery cold-wakes the exact saved session with `--session` (never `--session-id`, which would create a
|
|
1309
|
+
// new conversation). The controller deliberately reports record-backed liveness, matching Claude headless.
|
|
1310
|
+
export const piHeadlessHarness: Harness = {
|
|
1311
|
+
...piHarness,
|
|
1312
|
+
id: 'pi-headless',
|
|
1313
|
+
headless: true,
|
|
1314
|
+
messageStream: false,
|
|
1315
|
+
paneTitleIsSelfSummary: false,
|
|
1316
|
+
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
1317
|
+
liveness: () => 'online',
|
|
1318
|
+
deliver: deliverViaPiHeadless,
|
|
1319
|
+
cleanupRuntime: (rec) => {
|
|
1320
|
+
try { rmSync(piHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ }
|
|
1321
|
+
try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ }
|
|
1322
|
+
},
|
|
1323
|
+
deliveryBlockedBy: undefined,
|
|
1324
|
+
resumeArg: (rec) => `--session ${rec.session}`,
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1228
1327
|
export const opencodeHarness: Harness = {
|
|
1229
1328
|
id: 'opencode',
|
|
1329
|
+
headless: false,
|
|
1330
|
+
messageStream: false,
|
|
1230
1331
|
events: OPENCODE_EVENTS,
|
|
1231
1332
|
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
1232
1333
|
// socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
|
|
@@ -1261,6 +1362,7 @@ export const opencodeHarness: Harness = {
|
|
|
1261
1362
|
liveness: (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
|
|
1262
1363
|
(tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline'),
|
|
1263
1364
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1365
|
+
cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
|
|
1264
1366
|
// owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
|
|
1265
1367
|
// SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
|
|
1266
1368
|
// which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
|
|
@@ -1268,8 +1370,32 @@ export const opencodeHarness: Harness = {
|
|
|
1268
1370
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
|
|
1269
1371
|
}
|
|
1270
1372
|
|
|
1373
|
+
// OpenCode headless is a separate harness, not an opencode mode. Its materialize half is exactly
|
|
1374
|
+
// opencodeHarness; only the one-turn runtime and its capability row differ.
|
|
1375
|
+
export const opencodeHeadlessHarness: Harness = {
|
|
1376
|
+
...opencodeHarness,
|
|
1377
|
+
id: 'opencode-headless',
|
|
1378
|
+
headless: true,
|
|
1379
|
+
messageStream: false,
|
|
1380
|
+
launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
|
|
1381
|
+
// A sleeping native conversation is still addressable by its record. Transport breakage belongs to the
|
|
1382
|
+
// next delivery, where the live rendezvous or pane wake reports it loudly.
|
|
1383
|
+
liveness: () => 'online',
|
|
1384
|
+
deliver: async (rec, text) => {
|
|
1385
|
+
const probe = await rendezvousListening(rec.session)
|
|
1386
|
+
if (probe === 'live') return deliverViaRendezvous(rec.session, text)
|
|
1387
|
+
if (probe === 'unproven') {
|
|
1388
|
+
return {
|
|
1389
|
+
ok: false,
|
|
1390
|
+
error: `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session))
|
|
1394
|
+
},
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1271
1397
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
1272
|
-
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness]
|
|
1398
|
+
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
|
|
1273
1399
|
|
|
1274
1400
|
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
1275
1401
|
export const defaultHarness: Harness = claudeHarness
|
|
@@ -1286,20 +1412,31 @@ export function harnessById(id: string): Harness {
|
|
|
1286
1412
|
// human-chosen name. `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
|
|
1287
1413
|
// launchers (with the regular command path), so they are edited like any other. harness defaults to claude.
|
|
1288
1414
|
// resolveLauncher throws fail-loud on an unknown name (a session must never silently launch under the wrong
|
|
1289
|
-
// auth) and validates the harness id. There is NO env-derived built-in fallback:
|
|
1290
|
-
// the config's real launchers.
|
|
1291
|
-
export type Launcher = { name: string; harness: string; cmd: string }
|
|
1415
|
+
// auth) and validates the harness id. There is NO env-derived built-in fallback: this registry lists exactly
|
|
1416
|
+
// the config's real launchers; dashboardLauncherList applies only the dashboard visibility projection.
|
|
1417
|
+
export type Launcher = { name: string; harness: string; cmd: string; headless: boolean }
|
|
1292
1418
|
export type LauncherDefault = { default: string | null; error: string | null }
|
|
1293
1419
|
|
|
1294
|
-
// the configured named launchers from spexcode.json, as a stable name-sorted list (for
|
|
1295
|
-
//
|
|
1420
|
+
// the complete configured named launchers from spexcode.json, as a stable name-sorted list (for CLI/session
|
|
1421
|
+
// resolution and downstream projections). Picking a launcher is the ONLY launch choice; the old separate
|
|
1422
|
+
// harness pick is gone.
|
|
1296
1423
|
export function launcherList(root = mainCheckout()): Launcher[] {
|
|
1297
1424
|
const m = readConfig(root).sessions?.launchers || {}
|
|
1298
1425
|
return Object.keys(m)
|
|
1299
|
-
.map((name) =>
|
|
1426
|
+
.map((name) => {
|
|
1427
|
+
const harness = harnessById(m[name].harness || defaultHarness.id)
|
|
1428
|
+
return { name, harness: harness.id, cmd: m[name].cmd, headless: harness.headless }
|
|
1429
|
+
})
|
|
1300
1430
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1301
1431
|
}
|
|
1302
1432
|
|
|
1433
|
+
// The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
|
|
1434
|
+
// it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
|
|
1435
|
+
export function dashboardLauncherList(root = mainCheckout()): Launcher[] {
|
|
1436
|
+
const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true
|
|
1437
|
+
return launcherList(root).filter((launcher) => showHeadless || !launcher.headless)
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1303
1440
|
export const MISSING_DEFAULT_LAUNCHER_ERROR =
|
|
1304
1441
|
'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
|
|
1305
1442
|
|
|
@@ -1326,7 +1463,6 @@ export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
|
|
|
1326
1463
|
const l = readConfig(root).sessions?.launchers?.[name]
|
|
1327
1464
|
if (!l) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
|
|
1328
1465
|
if (!l.cmd) throw new Error(`launcher '${name}' is missing cmd`)
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
return resolved
|
|
1466
|
+
const harness = harnessById(l.harness || defaultHarness.id) // validate the harness id fail-loud
|
|
1467
|
+
return { name, harness: harness.id, cmd: l.cmd, headless: harness.headless }
|
|
1332
1468
|
}
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -202,6 +202,7 @@ edit the spec instead — same commit as the code.`,
|
|
|
202
202
|
|
|
203
203
|
Control another session (all take SEL):
|
|
204
204
|
spex session send <SEL> "<msg>" deliver a message (fail-loud: a dead dispatch exits non-zero)
|
|
205
|
+
spex session interrupt <SEL> hard-interrupt the current turn through native harness control
|
|
205
206
|
spex session send <SEL> --keys "<keys>"
|
|
206
207
|
LAST RESORT: raw nav-mode keystrokes to a TUI dialog ("Up Up Enter", C-/M-/S- combos). The raw
|
|
207
208
|
key surface is UNSTABLE and can confirm dangerous dialogs — don't reach for it unless a plain
|
|
@@ -225,7 +226,7 @@ Human escape hatch:
|
|
|
225
226
|
show --capture / send. LOCAL-only (fails loud on a remote backend).
|
|
226
227
|
|
|
227
228
|
${SEL_NOTE}
|
|
228
|
-
Manager verbs that WRITE (send/rename/resume/stop/close/merge) are PROJECT-BOUND: a backend serving
|
|
229
|
+
Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge) are PROJECT-BOUND: a backend serving
|
|
229
230
|
another project's repo refuses loudly — name the target with --api <url> to drive it on purpose.
|
|
230
231
|
${MENTION_NOTE}`,
|
|
231
232
|
see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
|
|
@@ -360,6 +361,8 @@ Machine plumbing — called by generated hooks and launch scripts, never typed b
|
|
|
360
361
|
nudge <node> the post-merge hook prints the issue nudge for a merged node
|
|
361
362
|
codex-launch <sock> <cwd> [prompt…] backend-owned codex thread/start + first turn (launch script)
|
|
362
363
|
codex-turn <sock> <threadId> <text…> fire a follow-up turn on an owned thread (tests/scripts)
|
|
364
|
+
claude-headless-run <id> <runtime> <cmd> -- <tail…> resident stream-json controller (launch script)
|
|
365
|
+
pi-headless-run <id> <runtime> <cmd> -- <tail…> resident pi text-mode controller (launch script)
|
|
363
366
|
|
|
364
367
|
If you reached for one of these by hand, the porcelain you want is probably elsewhere: the trunk
|
|
365
368
|
name also lives at GET /api/settings (.layout); sessions are driven with spex session new / session send;
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -15,9 +15,10 @@ import { resolveLayout, mainBranch } from './layout.js'
|
|
|
15
15
|
import { getBoardJson } from './graphCache.js'
|
|
16
16
|
import { boardStream, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
17
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
|
-
import { listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, TMUX_SOCK } from './sessions.js'
|
|
18
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, TMUX_SOCK } from './sessions.js'
|
|
19
19
|
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
20
|
-
import {
|
|
20
|
+
import { readSessionMessages, sessionMessageStream } from './message-stream.js'
|
|
21
|
+
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
21
22
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
22
23
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
23
24
|
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
@@ -175,11 +176,11 @@ app.post('/api/evidence', async (c) => {
|
|
|
175
176
|
})
|
|
176
177
|
// the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
|
|
177
178
|
// `layout` (resolveLayout()'s main/worktrees/branch shape — the write-guard's project-identity probe reads
|
|
178
|
-
// `.layout.main`) and the
|
|
179
|
-
// offers — `{ name, harness, cmd }`: the cmd is read-only display data for the picker (the dashboard sits
|
|
179
|
+
// `.layout.main`) and the dashboard-visible launcher profiles ([[launcher-visibility]]) the New-Session picker
|
|
180
|
+
// offers — `{ name, harness, cmd, headless }`: the cmd is read-only display data for the picker (the dashboard sits
|
|
180
181
|
// behind the gateway auth; the browser can read but never edit config) — plus the configured `default` NAME
|
|
181
|
-
// so the picker pre-selects the SAME
|
|
182
|
-
//
|
|
182
|
+
// so the picker pre-selects the SAME launcher a bare `spex session new` uses when that row is visible, else
|
|
183
|
+
// its first visible row rather than a hidden headless default,
|
|
183
184
|
// Missing defaultLauncher is returned as an actionable config error, not hidden by falling through to the
|
|
184
185
|
// built-in `claude` launcher.
|
|
185
186
|
// `tmuxSocket` is the `-L <name>` label our private tmux server runs under (a backend fact, env-overridable),
|
|
@@ -187,7 +188,7 @@ app.post('/api/evidence', async (c) => {
|
|
|
187
188
|
// beside the blessed `spex session attach` command — the frontend never hardcodes the socket.
|
|
188
189
|
app.get('/api/settings', async (c) => c.json({
|
|
189
190
|
layout: await resolveLayout(),
|
|
190
|
-
launchers:
|
|
191
|
+
launchers: dashboardLauncherList(),
|
|
191
192
|
tmuxSocket: TMUX_SOCK,
|
|
192
193
|
...launcherDefault(),
|
|
193
194
|
}))
|
|
@@ -436,6 +437,13 @@ app.get('/api/sessions/:id/timeline', (c) => {
|
|
|
436
437
|
const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined)
|
|
437
438
|
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
438
439
|
})
|
|
440
|
+
// An adapter may expose its native event log as the console's optional full-process drill-down. REST establishes
|
|
441
|
+
// the complete ordered snapshot + byte cursor; SSE follows appends from that cursor (or Last-Event-ID on reconnect).
|
|
442
|
+
app.get('/api/sessions/:id/messages', (c) => {
|
|
443
|
+
const r = readSessionMessages(c.req.param('id'))
|
|
444
|
+
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
445
|
+
})
|
|
446
|
+
app.get('/api/sessions/:id/messages/stream', (c) => sessionMessageStream(c))
|
|
439
447
|
// the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
|
|
440
448
|
// plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
|
|
441
449
|
// the CLI's show; 404 for an unknown id.
|
|
@@ -567,6 +575,10 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
567
575
|
// soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
|
|
568
576
|
// removes the worktree. {ok:false} = no such session.
|
|
569
577
|
app.post('/api/sessions/:id/stop', async (c) => c.json({ ok: await stopSession(c.req.param('id')) }))
|
|
578
|
+
app.post('/api/sessions/:id/interrupt', async (c) => {
|
|
579
|
+
const result = await interruptSession(c.req.param('id'))
|
|
580
|
+
return c.json(result, result.ok ? 200 : 502)
|
|
581
|
+
})
|
|
570
582
|
app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
|
|
571
583
|
// set (or clear, with a blank) a session's display-name override; persists to the session's global record
|
|
572
584
|
// (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -27,6 +27,7 @@ type Config = {
|
|
|
27
27
|
apiUrl?: string // the per-project backend the board proxies to (read frontend-side; see api-endpoint)
|
|
28
28
|
title?: string // override for the browser-tab name (default: the repo-root basename; see tab-title)
|
|
29
29
|
icon?: string // project identity icon: a picker preset id; existing emoji/Iconify/URL values remain supported ([[identity-config]])
|
|
30
|
+
showHeadlessLaunchers?: boolean // include headless harness profiles in the dashboard New Session picker (default false; [[launcher-visibility]])
|
|
30
31
|
}
|
|
31
32
|
sessions?: {
|
|
32
33
|
maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync, watch, type FSWatcher } from 'node:fs'
|
|
2
|
+
import { streamSSE } from 'hono/streaming'
|
|
3
|
+
import type { Context } from 'hono'
|
|
4
|
+
|
|
5
|
+
import { readAliasedRawRecord, sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
6
|
+
|
|
7
|
+
export type NativeMessageEvent = Record<string, unknown>
|
|
8
|
+
export type MessageEnvelope = { cursor: number; event: NativeMessageEvent }
|
|
9
|
+
export type MessageBatch = { messages: MessageEnvelope[]; cursor: number }
|
|
10
|
+
|
|
11
|
+
function fileBytes(path: string, cursor: number): Buffer {
|
|
12
|
+
let fd: number
|
|
13
|
+
try { fd = openSync(path, 'r') }
|
|
14
|
+
catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && cursor === 0) return Buffer.alloc(0)
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const size = fstatSync(fd).size
|
|
20
|
+
if (cursor > size) throw new Error(`messages.ndjson cursor ${cursor} is past file size ${size}`)
|
|
21
|
+
const bytes = Buffer.alloc(size - cursor)
|
|
22
|
+
let read = 0
|
|
23
|
+
while (read < bytes.length) {
|
|
24
|
+
const count = readSync(fd, bytes, read, bytes.length - read, cursor + read)
|
|
25
|
+
if (count === 0) break
|
|
26
|
+
read += count
|
|
27
|
+
}
|
|
28
|
+
return read === bytes.length ? bytes : bytes.subarray(0, read)
|
|
29
|
+
} finally { closeSync(fd) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Parse only newline-terminated records. The cursor is a byte offset, not a character count, so an SSE
|
|
33
|
+
// reconnect resumes correctly even when a native event contains non-ASCII text.
|
|
34
|
+
export function readMessageBatchFile(path: string, requestedCursor = 0): MessageBatch {
|
|
35
|
+
const cursor = Number.isSafeInteger(requestedCursor) && requestedCursor >= 0 ? requestedCursor : 0
|
|
36
|
+
const bytes = fileBytes(path, cursor)
|
|
37
|
+
|
|
38
|
+
const messages: MessageEnvelope[] = []
|
|
39
|
+
let lineStart = 0
|
|
40
|
+
while (lineStart < bytes.length) {
|
|
41
|
+
const newline = bytes.indexOf(0x0a, lineStart)
|
|
42
|
+
if (newline < 0) break
|
|
43
|
+
const nextCursor = cursor + newline + 1
|
|
44
|
+
const text = bytes.subarray(lineStart, newline).toString('utf8')
|
|
45
|
+
let event: unknown
|
|
46
|
+
try { event = JSON.parse(text) }
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error(`invalid messages.ndjson event at byte ${cursor + lineStart}: ${(error as Error).message}`)
|
|
49
|
+
}
|
|
50
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
51
|
+
throw new Error(`invalid messages.ndjson event at byte ${cursor + lineStart}: expected a JSON object`)
|
|
52
|
+
}
|
|
53
|
+
messages.push({ cursor: nextCursor, event: event as NativeMessageEvent })
|
|
54
|
+
lineStart = newline + 1
|
|
55
|
+
}
|
|
56
|
+
return { messages, cursor: cursor + lineStart }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// null means the id is unknown or is not a governed session. A known session with no adapter output yet is
|
|
60
|
+
// an ordinary empty stream: the adapter may create messages.ndjson after the browser has connected.
|
|
61
|
+
export function readSessionMessages(id: string, cursor = 0): MessageBatch | null {
|
|
62
|
+
const record = readAliasedRawRecord(id)
|
|
63
|
+
if (!record?.governed) return null
|
|
64
|
+
return readMessageBatchFile(sessionArtifactPath(record.session_id, 'messages.ndjson'), cursor)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type StreamSignal = 'append' | 'ping' | 'abort' | 'watch-error'
|
|
68
|
+
|
|
69
|
+
function signalQueue() {
|
|
70
|
+
const queued: StreamSignal[] = []
|
|
71
|
+
let waiter: ((signal: StreamSignal) => void) | null = null
|
|
72
|
+
const push = (signal: StreamSignal) => {
|
|
73
|
+
if (signal === 'append' && queued.includes('append')) return
|
|
74
|
+
if (waiter) {
|
|
75
|
+
const resolve = waiter
|
|
76
|
+
waiter = null
|
|
77
|
+
resolve(signal)
|
|
78
|
+
} else queued.push(signal)
|
|
79
|
+
}
|
|
80
|
+
const next = (): Promise<StreamSignal> => {
|
|
81
|
+
const ready = queued.shift()
|
|
82
|
+
return ready ? Promise.resolve(ready) : new Promise((resolve) => { waiter = resolve })
|
|
83
|
+
}
|
|
84
|
+
return { push, next }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cursorParam(value: string | undefined): number | null {
|
|
88
|
+
if (!value || !/^\d+$/.test(value)) return null
|
|
89
|
+
const cursor = Number(value)
|
|
90
|
+
return Number.isSafeInteger(cursor) ? cursor : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// GET /api/sessions/:id/messages/stream — the append-follow half of [[message-stream]]. REST establishes a
|
|
94
|
+
// complete snapshot + cursor; this stream starts there. Standard SSE ids let EventSource reconnect through
|
|
95
|
+
// Last-Event-ID even though its original URL still carries the older cursor.
|
|
96
|
+
export function sessionMessageStream(c: Context): Response {
|
|
97
|
+
const record = readAliasedRawRecord(c.req.param('id') as string)
|
|
98
|
+
if (!record?.governed) return c.json({ error: 'no such session' }, 404)
|
|
99
|
+
|
|
100
|
+
const lastEventCursor = cursorParam(c.req.header('Last-Event-ID'))
|
|
101
|
+
const queryCursor = cursorParam(c.req.query('cursor'))
|
|
102
|
+
const startCursor = lastEventCursor ?? queryCursor ?? 0
|
|
103
|
+
const id = record.session_id
|
|
104
|
+
|
|
105
|
+
return streamSSE(c, async (stream) => {
|
|
106
|
+
const signals = signalQueue()
|
|
107
|
+
let aborted = false
|
|
108
|
+
let watcher: FSWatcher | null = null
|
|
109
|
+
const ping = setInterval(() => signals.push('ping'), 10_000)
|
|
110
|
+
ping.unref()
|
|
111
|
+
stream.onAbort(() => { aborted = true; signals.push('abort') })
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
watcher = watch(sessionStoreDir(id), { persistent: false }, (_event, filename) => {
|
|
115
|
+
if (filename == null || String(filename) === 'messages.ndjson') signals.push('append')
|
|
116
|
+
})
|
|
117
|
+
watcher.on('error', () => signals.push('watch-error'))
|
|
118
|
+
|
|
119
|
+
let cursor = startCursor
|
|
120
|
+
await stream.writeSSE({ event: 'ready', data: JSON.stringify({ cursor }) })
|
|
121
|
+
const flush = async () => {
|
|
122
|
+
const batch = readSessionMessages(id, cursor)
|
|
123
|
+
if (!batch) throw new Error('session closed')
|
|
124
|
+
cursor = batch.cursor
|
|
125
|
+
for (const message of batch.messages) {
|
|
126
|
+
await stream.writeSSE({ event: 'message', id: String(message.cursor), data: JSON.stringify(message) })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
await flush()
|
|
130
|
+
|
|
131
|
+
while (!aborted) {
|
|
132
|
+
const signal = await signals.next()
|
|
133
|
+
if (signal === 'abort') break
|
|
134
|
+
if (signal === 'watch-error') throw new Error('messages.ndjson watcher failed')
|
|
135
|
+
if (signal === 'ping') await stream.writeSSE({ event: 'ping', data: 'x' })
|
|
136
|
+
else await flush()
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!aborted) {
|
|
140
|
+
await stream.writeSSE({ event: 'stream-error', data: JSON.stringify({ error: (error as Error).message }) }).catch(() => {})
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
clearInterval(ping)
|
|
144
|
+
watcher?.close()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
4
|
+
|
|
5
|
+
const pexec = promisify(execFile)
|
|
6
|
+
|
|
7
|
+
const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
8
|
+
|
|
9
|
+
// A headless turn owns the pane only while `opencode run` is alive. Returning to a shell keeps the tmux
|
|
10
|
+
// window as the session's durable home without adding a resident controller or an stdin bridge.
|
|
11
|
+
function turnHome(command: string): string {
|
|
12
|
+
const script = [
|
|
13
|
+
command,
|
|
14
|
+
'__spex_rc=$?',
|
|
15
|
+
'[ "$__spex_rc" -eq 0 ] || printf "[spex opencode-headless] turn exited rc=%s\\n" "$__spex_rc" >&2',
|
|
16
|
+
'exec "${SHELL:-/bin/sh}"',
|
|
17
|
+
].join('\n')
|
|
18
|
+
return `bash -lc ${shQuote(script)} spexcode-opencode-headless`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Launcher profiles carry a base executable plus its configured flags (`opencode --auto`). OpenCode parses
|
|
22
|
+
// `run` as a subcommand, so it must sit between those two halves (`opencode run --auto`), not at the tail.
|
|
23
|
+
function runPrelude(opencodeCmd: string): string[] {
|
|
24
|
+
return [
|
|
25
|
+
`__spex_cmd=(${opencodeCmd})`,
|
|
26
|
+
'__spex_env=()',
|
|
27
|
+
'while [ "${#__spex_cmd[@]}" -gt 0 ] && [[ "${__spex_cmd[0]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do',
|
|
28
|
+
' __spex_env+=("${__spex_cmd[0]}")',
|
|
29
|
+
' __spex_cmd=("${__spex_cmd[@]:1}")',
|
|
30
|
+
'done',
|
|
31
|
+
'__spex_run() { env "${__spex_env[@]}" "${__spex_cmd[0]}" run "${__spex_cmd[@]:1}" "$@"; }',
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function opencodeHeadlessLaunchCommand(opencodeCmd = 'opencode'): string {
|
|
36
|
+
const script = [
|
|
37
|
+
...runPrelude(opencodeCmd),
|
|
38
|
+
'if [ "${1:-}" = "--resume" ]; then',
|
|
39
|
+
' export SPEXCODE_OPENCODE_RESUME_ID="$2"',
|
|
40
|
+
' unset SPEXCODE_OPENCODE_CONTINUE',
|
|
41
|
+
' __spex_run --session "$2"',
|
|
42
|
+
'elif [ "${1:-}" = "--continue" ]; then',
|
|
43
|
+
' unset SPEXCODE_OPENCODE_RESUME_ID',
|
|
44
|
+
' export SPEXCODE_OPENCODE_CONTINUE=1',
|
|
45
|
+
' __spex_run --continue',
|
|
46
|
+
'elif [ -n "${1:-}" ]; then',
|
|
47
|
+
' unset SPEXCODE_OPENCODE_RESUME_ID SPEXCODE_OPENCODE_CONTINUE',
|
|
48
|
+
' __spex_run "$1"',
|
|
49
|
+
'else',
|
|
50
|
+
' __spex_run',
|
|
51
|
+
'fi',
|
|
52
|
+
].join('\n')
|
|
53
|
+
return turnHome(script)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function opencodeHeadlessWakeCommand(opencodeCmd: string, harnessSessionId: string | null | undefined, text: string): string {
|
|
57
|
+
const resume = harnessSessionId ? [
|
|
58
|
+
`export SPEXCODE_OPENCODE_RESUME_ID=${shQuote(harnessSessionId)}`,
|
|
59
|
+
'unset SPEXCODE_OPENCODE_CONTINUE',
|
|
60
|
+
`__spex_run --session ${shQuote(harnessSessionId)} ${shQuote(text)}`,
|
|
61
|
+
] : [
|
|
62
|
+
'unset SPEXCODE_OPENCODE_RESUME_ID',
|
|
63
|
+
'export SPEXCODE_OPENCODE_CONTINUE=1',
|
|
64
|
+
`__spex_run --continue ${shQuote(text)}`,
|
|
65
|
+
]
|
|
66
|
+
return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function spawnOpenCodeHeadlessTurn(
|
|
70
|
+
rec: HarnessDeliveryRecord,
|
|
71
|
+
text: string,
|
|
72
|
+
opencodeCmd: string,
|
|
73
|
+
socketPath: string,
|
|
74
|
+
): Promise<DispatchResult> {
|
|
75
|
+
if (!rec.worktreePath) return { ok: false, error: `opencode-headless session ${rec.session} has no worktree path - turn NOT started` }
|
|
76
|
+
const tmuxSock = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
77
|
+
const args = [
|
|
78
|
+
'-L', tmuxSock, 'respawn-pane', '-k', '-t', rec.session, '-c', rec.worktreePath,
|
|
79
|
+
'-e', `SPEXCODE_SESSION_ID=${rec.session}`,
|
|
80
|
+
'-e', 'CLAUDE_BG_BACKEND=daemon',
|
|
81
|
+
'-e', `CLAUDE_BG_RENDEZVOUS_SOCK=${socketPath}`,
|
|
82
|
+
]
|
|
83
|
+
for (const name of ['SPEXCODE_HOME', 'CODEX_HOME']) {
|
|
84
|
+
const value = process.env[name]
|
|
85
|
+
if (value) args.push('-e', `${name}=${value}`)
|
|
86
|
+
}
|
|
87
|
+
args.push(opencodeHeadlessWakeCommand(opencodeCmd, rec.harnessSessionId, text))
|
|
88
|
+
try {
|
|
89
|
+
await pexec('tmux', args, { timeout: 5_000 })
|
|
90
|
+
return { ok: true }
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
93
|
+
return { ok: false, error: `opencode-headless could not start a turn for session ${rec.session}: ${detail}` }
|
|
94
|
+
}
|
|
95
|
+
}
|