spexcode 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/src/claude-headless.ts +271 -0
  4. package/spec-cli/src/cli.ts +24 -1
  5. package/spec-cli/src/client.ts +8 -0
  6. package/spec-cli/src/guide.ts +18 -9
  7. package/spec-cli/src/harness.ts +120 -12
  8. package/spec-cli/src/help.ts +4 -1
  9. package/spec-cli/src/index.ts +19 -7
  10. package/spec-cli/src/layout.ts +1 -0
  11. package/spec-cli/src/message-stream.ts +147 -0
  12. package/spec-cli/src/opencode-headless.ts +95 -0
  13. package/spec-cli/src/pi-headless.ts +195 -0
  14. package/spec-cli/src/sessions.ts +23 -9
  15. package/spec-cli/templates/spexcode.json +7 -1
  16. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  17. package/spec-dashboard/dist/assets/{EvalsPage-DmiX3rdU.js → EvalsPage-5_nfIYll.js} +2 -2
  18. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  19. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  20. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  21. package/spec-dashboard/dist/assets/{PageScroll-C15adEYI.js → PageScroll-B_dKCuXx.js} +1 -1
  22. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  23. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  24. package/spec-dashboard/dist/assets/{SessionWindow-CuDO_67z.js → SessionWindow-BuJ5mzjC.js} +1 -1
  25. package/spec-dashboard/dist/assets/{Settings-C_N1wX1f.js → Settings-B8KFocsz.js} +1 -1
  26. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  27. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  28. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  29. package/spec-dashboard/dist/index.html +2 -2
  30. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +0 -27
  31. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +0 -1
  32. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +0 -2
  33. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +0 -1
  34. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +0 -1
  35. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +0 -39
  36. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +0 -41
  37. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +0 -1
@@ -9,6 +9,9 @@ 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 { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
14
+ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
12
15
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
13
16
  import { git } from './git.js'
14
17
 
@@ -24,7 +27,7 @@ import { git } from './git.js'
24
27
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
25
28
  // (materialize writes every harness's artifacts).
26
29
 
27
- export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi'
30
+ export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'opencode-headless' | 'pi-headless'
28
31
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
29
32
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
30
33
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
@@ -41,6 +44,12 @@ export type PaneProbe = { panePid?: number; procs?: ProcTable; pidAlive?: boolea
41
44
 
42
45
  export interface Harness {
43
46
  readonly id: HarnessId
47
+ // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
48
+ // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
49
+ readonly headless: boolean
50
+ // whether this harness persists a native event stream that the console may expose as an optional
51
+ // full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
52
+ readonly messageStream: boolean
44
53
  // the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
45
54
  // Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
46
55
  // idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
@@ -144,6 +153,13 @@ export interface Harness {
144
153
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
145
154
  // Returns ok=false with a reason that propagates to the API.
146
155
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
156
+ // Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
157
+ // without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
158
+ interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
159
+ // Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
160
+ // launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
161
+ // per-session socket. Product teardown calls only this adapter method.
162
+ cleanupRuntime(rec: HarnessLivenessRecord): void
147
163
  // the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
148
164
  // socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
149
165
  // the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
@@ -178,7 +194,13 @@ export interface Harness {
178
194
  // (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
179
195
  // re-exports it for its existing importers.
180
196
  export type DispatchResult = { ok: boolean; error?: string }
181
- export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
197
+ export type HarnessDeliveryRecord = {
198
+ session: string
199
+ worktreePath?: string
200
+ harnessSessionId?: string | null
201
+ runtimeDir?: string
202
+ launchCmd?: string | null
203
+ }
182
204
  // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
183
205
  // skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
184
206
  // also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
@@ -1079,6 +1101,8 @@ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
1079
1101
 
1080
1102
  export const claudeHarness: Harness = {
1081
1103
  id: 'claude',
1104
+ headless: false,
1105
+ messageStream: false,
1082
1106
  events: CLAUDE_EVENTS,
1083
1107
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
1084
1108
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
@@ -1101,6 +1125,7 @@ export const claudeHarness: Harness = {
1101
1125
  // dead-pane-reads-working bug). See rendezvousListening.
1102
1126
  liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
1103
1127
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1128
+ cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1104
1129
  // the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
1105
1130
  // and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
1106
1131
  // the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
@@ -1113,8 +1138,29 @@ export const claudeHarness: Harness = {
1113
1138
  resumeArg: (rec) => `--resume ${rec.session}`,
1114
1139
  }
1115
1140
 
1141
+ // Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
1142
+ // reused by object composition; the whole runtime half is replaced by the stream-json controller.
1143
+ export const claudeHeadlessHarness: Harness = {
1144
+ ...claudeHarness,
1145
+ id: 'claude-headless',
1146
+ headless: true,
1147
+ messageStream: true,
1148
+ ownsRendezvous: false,
1149
+ paneTitleIsSelfSummary: false,
1150
+ launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
1151
+ // Liveness is the intact record's property. A missing controller/child fails loudly at control time rather
1152
+ // than turning an idle (no child) session into a speculative offline row.
1153
+ liveness: () => 'online',
1154
+ deliver: deliverViaClaudeHeadless,
1155
+ interrupt: interruptClaudeHeadless,
1156
+ cleanupRuntime: (rec) => { try { rmSync(claudeHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ } },
1157
+ deliveryBlockedBy: undefined,
1158
+ }
1159
+
1116
1160
  export const codexHarness: Harness = {
1117
1161
  id: 'codex',
1162
+ headless: false,
1163
+ messageStream: false,
1118
1164
  events: CODEX_EVENTS,
1119
1165
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
1120
1166
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
@@ -1177,6 +1223,7 @@ export const codexHarness: Harness = {
1177
1223
  return paneTreeRunsCodex(pane) ? 'online' : 'offline'
1178
1224
  },
1179
1225
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
1226
+ cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
1180
1227
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
1181
1228
  // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
1182
1229
  // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
@@ -1196,6 +1243,8 @@ export const codexHarness: Harness = {
1196
1243
  // one-run defence. See pi-harness.ts for the extension source + trust mechanics.
1197
1244
  export const piHarness: Harness = {
1198
1245
  id: 'pi',
1246
+ headless: false,
1247
+ messageStream: false,
1199
1248
  events: PI_EVENTS,
1200
1249
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
1201
1250
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
@@ -1220,13 +1269,37 @@ export const piHarness: Harness = {
1220
1269
  // socket the generated extension binds. socketLive is already probed for every windowed session.
1221
1270
  liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
1222
1271
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1272
+ cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1223
1273
  // reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
1224
1274
  // LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
1225
1275
  resumeArg: (rec) => `--session ${rec.session}`,
1226
1276
  }
1227
1277
 
1278
+ // pi-headless is an independent harness: its materialization surface is literally pi's, while a resident
1279
+ // controller owns non-interactive text-mode turns. Active turns steer through pi's rendezvous extension;
1280
+ // idle delivery cold-wakes the exact saved session with `--session` (never `--session-id`, which would create a
1281
+ // new conversation). The controller deliberately reports record-backed liveness, matching Claude headless.
1282
+ export const piHeadlessHarness: Harness = {
1283
+ ...piHarness,
1284
+ id: 'pi-headless',
1285
+ headless: true,
1286
+ messageStream: false,
1287
+ paneTitleIsSelfSummary: false,
1288
+ launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
1289
+ liveness: () => 'online',
1290
+ deliver: deliverViaPiHeadless,
1291
+ cleanupRuntime: (rec) => {
1292
+ try { rmSync(piHeadlessSock(rec.session), { force: true }) } catch { /* already gone */ }
1293
+ try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ }
1294
+ },
1295
+ deliveryBlockedBy: undefined,
1296
+ resumeArg: (rec) => `--session ${rec.session}`,
1297
+ }
1298
+
1228
1299
  export const opencodeHarness: Harness = {
1229
1300
  id: 'opencode',
1301
+ headless: false,
1302
+ messageStream: false,
1230
1303
  events: OPENCODE_EVENTS,
1231
1304
  // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
1232
1305
  // socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
@@ -1261,6 +1334,7 @@ export const opencodeHarness: Harness = {
1261
1334
  liveness: (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
1262
1335
  (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline'),
1263
1336
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1337
+ cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
1264
1338
  // owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
1265
1339
  // SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
1266
1340
  // which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
@@ -1268,8 +1342,32 @@ export const opencodeHarness: Harness = {
1268
1342
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
1269
1343
  }
1270
1344
 
1345
+ // OpenCode headless is a separate harness, not an opencode mode. Its materialize half is exactly
1346
+ // opencodeHarness; only the one-turn runtime and its capability row differ.
1347
+ export const opencodeHeadlessHarness: Harness = {
1348
+ ...opencodeHarness,
1349
+ id: 'opencode-headless',
1350
+ headless: true,
1351
+ messageStream: false,
1352
+ launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
1353
+ // A sleeping native conversation is still addressable by its record. Transport breakage belongs to the
1354
+ // next delivery, where the live rendezvous or pane wake reports it loudly.
1355
+ liveness: () => 'online',
1356
+ deliver: async (rec, text) => {
1357
+ const probe = await rendezvousListening(rec.session)
1358
+ if (probe === 'live') return deliverViaRendezvous(rec.session, text)
1359
+ if (probe === 'unproven') {
1360
+ return {
1361
+ ok: false,
1362
+ error: `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
1363
+ }
1364
+ }
1365
+ return spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session))
1366
+ },
1367
+ }
1368
+
1271
1369
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
1272
- export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness]
1370
+ export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness]
1273
1371
 
1274
1372
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
1275
1373
  export const defaultHarness: Harness = claudeHarness
@@ -1286,20 +1384,31 @@ export function harnessById(id: string): Harness {
1286
1384
  // human-chosen name. `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
1287
1385
  // launchers (with the regular command path), so they are edited like any other. harness defaults to claude.
1288
1386
  // 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: the dropdown lists exactly
1290
- // the config's real launchers.
1291
- export type Launcher = { name: string; harness: string; cmd: string }
1387
+ // auth) and validates the harness id. There is NO env-derived built-in fallback: this registry lists exactly
1388
+ // the config's real launchers; dashboardLauncherList applies only the dashboard visibility projection.
1389
+ export type Launcher = { name: string; harness: string; cmd: string; headless: boolean }
1292
1390
  export type LauncherDefault = { default: string | null; error: string | null }
1293
1391
 
1294
- // the configured named launchers from spexcode.json, as a stable name-sorted list (for the dashboard dropdown
1295
- // + the CLI). Picking a launcher is the ONLY launch choice; the old separate harness pick is gone.
1392
+ // the complete configured named launchers from spexcode.json, as a stable name-sorted list (for CLI/session
1393
+ // resolution and downstream projections). Picking a launcher is the ONLY launch choice; the old separate
1394
+ // harness pick is gone.
1296
1395
  export function launcherList(root = mainCheckout()): Launcher[] {
1297
1396
  const m = readConfig(root).sessions?.launchers || {}
1298
1397
  return Object.keys(m)
1299
- .map((name) => ({ name, harness: m[name].harness || defaultHarness.id, cmd: m[name].cmd }))
1398
+ .map((name) => {
1399
+ const harness = harnessById(m[name].harness || defaultHarness.id)
1400
+ return { name, harness: harness.id, cmd: m[name].cmd, headless: harness.headless }
1401
+ })
1300
1402
  .sort((a, b) => a.name.localeCompare(b.name))
1301
1403
  }
1302
1404
 
1405
+ // The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
1406
+ // it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
1407
+ export function dashboardLauncherList(root = mainCheckout()): Launcher[] {
1408
+ const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true
1409
+ return launcherList(root).filter((launcher) => showHeadless || !launcher.headless)
1410
+ }
1411
+
1303
1412
  export const MISSING_DEFAULT_LAUNCHER_ERROR =
1304
1413
  'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
1305
1414
 
@@ -1326,7 +1435,6 @@ export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
1326
1435
  const l = readConfig(root).sessions?.launchers?.[name]
1327
1436
  if (!l) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
1328
1437
  if (!l.cmd) throw new Error(`launcher '${name}' is missing cmd`)
1329
- const resolved = { name, harness: l.harness || defaultHarness.id, cmd: l.cmd }
1330
- harnessById(resolved.harness) // validate the harness id fail-loud
1331
- return resolved
1438
+ const harness = harnessById(l.harness || defaultHarness.id) // validate the harness id fail-loud
1439
+ return { name, harness: harness.id, cmd: l.cmd, headless: harness.headless }
1332
1440
  }
@@ -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;
@@ -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 { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
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 named launcher profiles ([[launcher-select]]) the New-Session picker
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
- // launcher a bare `spex session new` uses (the CLI/config default), instead of the alphabetically-first one,
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: launcherList(),
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
@@ -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
+ }