spexcode 0.4.1 → 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.
- package/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/claude-headless.ts +271 -0
- package/spec-cli/src/cli.ts +112 -38
- package/spec-cli/src/client.ts +42 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +115 -56
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +295 -39
- package/spec-cli/src/help.ts +68 -28
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +158 -87
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +6 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/pi-headless.ts +195 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +211 -75
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +10 -2
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
- 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-B_dKCuXx.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.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/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
package/spec-cli/src/harness.ts
CHANGED
|
@@ -6,7 +6,12 @@ import { createConnection, type Socket } from 'node:net'
|
|
|
6
6
|
import { execFile, execFileSync } from 'node:child_process'
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
|
-
import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
|
|
9
|
+
import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands, type SlashCommand } from './slash-commands.js'
|
|
10
|
+
import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
|
|
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'
|
|
10
15
|
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
11
16
|
import { git } from './git.js'
|
|
12
17
|
|
|
@@ -22,7 +27,7 @@ import { git } from './git.js'
|
|
|
22
27
|
// payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
|
|
23
28
|
// (materialize writes every harness's artifacts).
|
|
24
29
|
|
|
25
|
-
export type HarnessId = 'claude' | 'codex'
|
|
30
|
+
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'opencode-headless' | 'pi-headless'
|
|
26
31
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
|
|
27
32
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
28
33
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
@@ -39,6 +44,12 @@ export type PaneProbe = { panePid?: number; procs?: ProcTable; pidAlive?: boolea
|
|
|
39
44
|
|
|
40
45
|
export interface Harness {
|
|
41
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
|
|
42
53
|
// the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
|
|
43
54
|
// Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
|
|
44
55
|
// idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
|
|
@@ -54,7 +65,6 @@ export interface Harness {
|
|
|
54
65
|
// instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
|
|
55
66
|
// is data on the adapter, not an `if (codex)` in sessions.ts.
|
|
56
67
|
readonly paneTitleIsSelfSummary: boolean
|
|
57
|
-
|
|
58
68
|
// --- launch / sessionId ---
|
|
59
69
|
// the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
|
|
60
70
|
// visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
|
|
@@ -101,16 +111,18 @@ export interface Harness {
|
|
|
101
111
|
// ONLY place agent-surface divergence lives (the skillDir analog). Claude reads .claude/agents/<name>.md;
|
|
102
112
|
// Codex has no file-discovered agent-definition primitive, so it returns null and materialize skips it.
|
|
103
113
|
agentDir(proj: string): string | null
|
|
104
|
-
// the shim payload:
|
|
105
|
-
//
|
|
106
|
-
|
|
114
|
+
// the shim payload: `content` is whatever artifact THIS harness auto-discovers to wire every event to the
|
|
115
|
+
// dispatcher (harness id baked in) — a settings/hooks JSON for claude/codex, a generated event-bus PLUGIN
|
|
116
|
+
// for opencode, a generated TypeScript EXTENSION for pi — plus the per-event command string (shared with
|
|
117
|
+
// the trust writer so they hash identically).
|
|
118
|
+
shim(dispatch: string, spex: string): { content: string; cmd: (e: string) => string }
|
|
107
119
|
// make a dispatched/self-launched agent run the hooks with zero prompts. Codex writes PROJECT trust — and, on
|
|
108
120
|
// a binary without `--dangerously-bypass-hook-trust`, per-hook trusted_hash blocks — into the GLOBAL
|
|
109
121
|
// ~/.codex/config.toml (codex's security model: trust is global-only). PROJECT trust is UNCONDITIONAL: it
|
|
110
122
|
// ENABLES the project config layer so codex discovers our hooks at all, a tier bypass_hook_trust does NOT
|
|
111
123
|
// cover. Claude is a no-op (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim
|
|
112
124
|
// emitted.
|
|
113
|
-
writeTrust(proj: string, cmdFor: (e: string) => string):
|
|
125
|
+
writeTrust(proj: string, cmdFor: (e: string) => string): readonly string[]
|
|
114
126
|
|
|
115
127
|
// --- the `/` menu ---
|
|
116
128
|
// the slash-command list, computed the way THIS harness computes its own `/` menu.
|
|
@@ -141,6 +153,13 @@ export interface Harness {
|
|
|
141
153
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
142
154
|
// Returns ok=false with a reason that propagates to the API.
|
|
143
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
|
|
144
163
|
// the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
|
|
145
164
|
// socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
|
|
146
165
|
// the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
|
|
@@ -175,7 +194,13 @@ export interface Harness {
|
|
|
175
194
|
// (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
176
195
|
// re-exports it for its existing importers.
|
|
177
196
|
export type DispatchResult = { ok: boolean; error?: string }
|
|
178
|
-
export type HarnessDeliveryRecord = {
|
|
197
|
+
export type HarnessDeliveryRecord = {
|
|
198
|
+
session: string
|
|
199
|
+
worktreePath?: string
|
|
200
|
+
harnessSessionId?: string | null
|
|
201
|
+
runtimeDir?: string
|
|
202
|
+
launchCmd?: string | null
|
|
203
|
+
}
|
|
179
204
|
// the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
|
|
180
205
|
// skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
|
|
181
206
|
// also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
|
|
@@ -421,7 +446,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
|
|
|
421
446
|
bypassProbe.set(binary, ok)
|
|
422
447
|
return ok
|
|
423
448
|
}
|
|
424
|
-
export function codexLaunchCommand(_id: string, codexCmd = 'codex
|
|
449
|
+
export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
|
|
425
450
|
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
426
451
|
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
427
452
|
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
@@ -756,13 +781,18 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
|
|
|
756
781
|
// rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
|
|
757
782
|
// found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
|
|
758
783
|
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
|
|
759
|
-
// does a rollout file for this thread id exist yet? Rollouts are grouped by date
|
|
760
|
-
//
|
|
784
|
+
// does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
|
|
785
|
+
// (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
|
|
786
|
+
// the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
|
|
787
|
+
// dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
|
|
788
|
+
// every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
|
|
789
|
+
// "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
|
|
761
790
|
export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
|
|
762
791
|
const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
792
|
+
for (const y of kids(root)) for (const m of kids(join(root, y))) for (const d of kids(join(root, y, m))) {
|
|
793
|
+
if (kids(join(root, y, m, d)).some((f) => f.includes(threadId))) return true
|
|
794
|
+
}
|
|
795
|
+
return false
|
|
766
796
|
}
|
|
767
797
|
// poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
|
|
768
798
|
// caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
|
|
@@ -846,11 +876,11 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
|
|
|
846
876
|
// the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
|
|
847
877
|
// baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
|
|
848
878
|
// the cli-needing handlers.
|
|
849
|
-
function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): {
|
|
879
|
+
function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { content: string; cmd: (e: string) => string } {
|
|
850
880
|
const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
|
|
851
881
|
const hooks: Record<string, unknown> = {}
|
|
852
882
|
for (const e of events) hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }]
|
|
853
|
-
return {
|
|
883
|
+
return { content: JSON.stringify({ hooks }, null, 2), cmd }
|
|
854
884
|
}
|
|
855
885
|
|
|
856
886
|
// ---------------------------------------------------------------------------------------------------------
|
|
@@ -909,7 +939,7 @@ function stripCodexTrustFor(cur: string, proj: string, hooksJson: string): strin
|
|
|
909
939
|
// leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
|
|
910
940
|
// Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
|
|
911
941
|
// (`events` may be empty for a trust-only stamp in tests.)
|
|
912
|
-
export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string):
|
|
942
|
+
export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): string {
|
|
913
943
|
const home = process.env.CODEX_HOME || join(homedir(), '.codex')
|
|
914
944
|
const file = join(home, 'config.toml')
|
|
915
945
|
const hooksJson = join(proj, '.codex', 'hooks.json')
|
|
@@ -922,6 +952,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
|
|
|
922
952
|
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
|
|
923
953
|
if (!existsSync(home)) mkdirSync(home, { recursive: true })
|
|
924
954
|
writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
|
|
955
|
+
return file
|
|
925
956
|
}
|
|
926
957
|
|
|
927
958
|
// the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
|
|
@@ -986,7 +1017,9 @@ function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
|
|
|
986
1017
|
// (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
|
|
987
1018
|
// may not yet contain codex — as 'starting', not 'offline'.
|
|
988
1019
|
const CODEXISH = /^(codex|node)/i // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
|
|
989
|
-
|
|
1020
|
+
// the shared descendant-tree walk: does a process matching `re` live BELOW the pane pid? (The pane pid itself
|
|
1021
|
+
// is the shell, so descendants only.) Pure over the caller's one ps snapshot.
|
|
1022
|
+
function paneTreeRuns(pane: PaneProbe | undefined, re: RegExp): boolean {
|
|
990
1023
|
if (!pane?.panePid || !pane.procs?.size) return false
|
|
991
1024
|
const kids = new Map<number, number[]>()
|
|
992
1025
|
for (const [pid, p] of pane.procs) {
|
|
@@ -996,27 +1029,80 @@ export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
|
|
|
996
1029
|
while (stack.length) {
|
|
997
1030
|
const pid = stack.pop()!
|
|
998
1031
|
const comm = pane.procs.get(pid)?.comm ?? ''
|
|
999
|
-
if (
|
|
1032
|
+
if (re.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
|
|
1000
1033
|
const c = kids.get(pid); if (c) stack.push(...c)
|
|
1001
1034
|
}
|
|
1002
1035
|
return false
|
|
1003
1036
|
}
|
|
1037
|
+
export function paneTreeRunsCodex(pane?: PaneProbe): boolean { return paneTreeRuns(pane, CODEXISH) }
|
|
1038
|
+
|
|
1039
|
+
// ONE whole-box pid→(ppid, comm) snapshot (a single `ps` spawn) — the table paneTreeRuns walks. Owned here
|
|
1040
|
+
// (beside its consumers) and shared with sessions.ts's liveSnapshot, so the two probe layers can never parse
|
|
1041
|
+
// ps differently. A failed/timed-out ps returns an empty table: the callers read that as not-provably-running.
|
|
1042
|
+
export async function procSnapshot(timeoutMs = 4000): Promise<ProcTable> {
|
|
1043
|
+
const t: ProcTable = new Map()
|
|
1044
|
+
let out = ''
|
|
1045
|
+
try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: timeoutMs, killSignal: 'SIGKILL' })) } catch { return t }
|
|
1046
|
+
for (const line of out.split('\n')) {
|
|
1047
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
|
|
1048
|
+
if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
|
|
1049
|
+
}
|
|
1050
|
+
return t
|
|
1051
|
+
}
|
|
1004
1052
|
|
|
1005
1053
|
// ---------------------------------------------------------------------------------------------------------
|
|
1006
1054
|
// the two implementations.
|
|
1007
1055
|
|
|
1008
1056
|
const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
|
|
1009
1057
|
const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
1058
|
+
// the five claude-shaped events pi's generated extension SYNTHESIZES from its own lifecycle (session_start →
|
|
1059
|
+
// SessionStart, input → UserPromptSubmit, tool_call → PreToolUse, tool_result → PostToolUse, agent_end +
|
|
1060
|
+
// agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
|
|
1061
|
+
// real gap as codex.
|
|
1062
|
+
const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
1010
1063
|
|
|
1011
1064
|
// the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
|
|
1012
|
-
// launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The
|
|
1013
|
-
// the fallback for a truly-old record with NO pinned cmd and NO launcher name —
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1016
|
-
const
|
|
1065
|
+
// launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
|
|
1066
|
+
// the fallback for a truly-old record with NO pinned cmd and NO launcher name — compatibility must preserve
|
|
1067
|
+
// the harness's normal permission model, never silently introduce an automatic-permission flag. There is no
|
|
1068
|
+
// env/config-field resolution because launchers are ordinary named config entries ([[launcher-select]]).
|
|
1069
|
+
const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
|
|
1070
|
+
const codexBaseCmd = (cmd?: string) => cmd || 'codex'
|
|
1071
|
+
const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
|
|
1072
|
+
const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
|
|
1073
|
+
|
|
1074
|
+
// @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
|
|
1075
|
+
// opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
|
|
1076
|
+
// prompt arg (a NEW launch → `--prompt`), or a resume marker from opencodeHarness.resumeArg: `--resume <id>`
|
|
1077
|
+
// re-attaches the owned opencode session (`--session <id>`, the SAME conversation), `--continue` re-attaches
|
|
1078
|
+
// the worktree's last session when no id was ever captured (the plugin failed before its first event). A new
|
|
1079
|
+
// launch's tail can never BE a literal marker (it's one quoted prompt), so the branch is unambiguous.
|
|
1080
|
+
export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
|
|
1081
|
+
const script = [
|
|
1082
|
+
`if [ "\${1:-}" = "--resume" ]; then`,
|
|
1083
|
+
// the marker carries the owned session id — export it so the plugin can seed rootSession at load: a
|
|
1084
|
+
// resumed session re-fires NO bus event until poked, so without this the rendezvous daemon rejects
|
|
1085
|
+
// every delivery (resume-continuity A-side: continuity ✓, steerability ✗).
|
|
1086
|
+
` export SPEXCODE_OPENCODE_RESUME_ID="$2"`,
|
|
1087
|
+
` exec ${opencodeCmd} --session "$2"`,
|
|
1088
|
+
`elif [ "\${1:-}" = "--continue" ]; then`,
|
|
1089
|
+
// no owned id to seed — mark the continue-resume so the plugin knows to ask the SDK for the
|
|
1090
|
+
// reattached session (scoped to this marker so a FRESH launch can never adopt a stale session).
|
|
1091
|
+
` export SPEXCODE_OPENCODE_CONTINUE=1`,
|
|
1092
|
+
` exec ${opencodeCmd} --continue`,
|
|
1093
|
+
`elif [ -n "\${1:-}" ]; then`,
|
|
1094
|
+
` exec ${opencodeCmd} --prompt "$1"`,
|
|
1095
|
+
`else`,
|
|
1096
|
+
` exec ${opencodeCmd}`,
|
|
1097
|
+
`fi`,
|
|
1098
|
+
].join('\n')
|
|
1099
|
+
return `bash -lc ${shQuote(script)} spexcode-opencode`
|
|
1100
|
+
}
|
|
1017
1101
|
|
|
1018
1102
|
export const claudeHarness: Harness = {
|
|
1019
1103
|
id: 'claude',
|
|
1104
|
+
headless: false,
|
|
1105
|
+
messageStream: false,
|
|
1020
1106
|
events: CLAUDE_EVENTS,
|
|
1021
1107
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
1022
1108
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
@@ -1030,7 +1116,7 @@ export const claudeHarness: Harness = {
|
|
|
1030
1116
|
skillDir: (proj) => join(proj, '.claude', 'skills'),
|
|
1031
1117
|
agentDir: (proj) => join(proj, '.claude', 'agents'),
|
|
1032
1118
|
shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
|
|
1033
|
-
writeTrust: () =>
|
|
1119
|
+
writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
|
|
1034
1120
|
removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
|
|
1035
1121
|
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
1036
1122
|
slashCommands: claudeSlashCommands,
|
|
@@ -1039,6 +1125,7 @@ export const claudeHarness: Harness = {
|
|
|
1039
1125
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
1040
1126
|
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
1041
1127
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1128
|
+
cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
|
|
1042
1129
|
// the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
|
|
1043
1130
|
// and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
|
|
1044
1131
|
// the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
|
|
@@ -1051,8 +1138,29 @@ export const claudeHarness: Harness = {
|
|
|
1051
1138
|
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
1052
1139
|
}
|
|
1053
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
|
+
|
|
1054
1160
|
export const codexHarness: Harness = {
|
|
1055
1161
|
id: 'codex',
|
|
1162
|
+
headless: false,
|
|
1163
|
+
messageStream: false,
|
|
1056
1164
|
events: CODEX_EVENTS,
|
|
1057
1165
|
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
1058
1166
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
@@ -1097,7 +1205,7 @@ export const codexHarness: Harness = {
|
|
|
1097
1205
|
// the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
|
|
1098
1206
|
// as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
|
|
1099
1207
|
// thread still runs the hooks); it does not REPLACE the hashes here.
|
|
1100
|
-
writeTrust: (proj, cmdFor) => writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor),
|
|
1208
|
+
writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
|
|
1101
1209
|
// trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
|
|
1102
1210
|
removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
|
|
1103
1211
|
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
@@ -1115,14 +1223,151 @@ export const codexHarness: Harness = {
|
|
|
1115
1223
|
return paneTreeRunsCodex(pane) ? 'online' : 'offline'
|
|
1116
1224
|
},
|
|
1117
1225
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
1226
|
+
cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
1118
1227
|
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
1119
1228
|
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
1120
1229
|
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
1121
1230
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
1122
1231
|
}
|
|
1123
1232
|
|
|
1233
|
+
// @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
|
|
1234
|
+
// the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
|
|
1235
|
+
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
1236
|
+
// hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
|
|
1237
|
+
// extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
|
|
1238
|
+
// dispatch.sh AND binds this session's rendezvous socket itself (sessions.ts already exports
|
|
1239
|
+
// CLAUDE_BG_RENDEZVOUS_SOCK to every ownsRendezvous launch) speaking the reclaude line protocol — so
|
|
1240
|
+
// deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
|
|
1241
|
+
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
1242
|
+
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
1243
|
+
// one-run defence. See pi-harness.ts for the extension source + trust mechanics.
|
|
1244
|
+
export const piHarness: Harness = {
|
|
1245
|
+
id: 'pi',
|
|
1246
|
+
headless: false,
|
|
1247
|
+
messageStream: false,
|
|
1248
|
+
events: PI_EVENTS,
|
|
1249
|
+
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
1250
|
+
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
1251
|
+
launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
|
|
1252
|
+
baseCmd: piBaseCmd,
|
|
1253
|
+
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
1254
|
+
sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
|
|
1255
|
+
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
1256
|
+
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
1257
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
1258
|
+
skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
|
|
1259
|
+
agentDir: () => null, // pi has no file-discovered sub-agent primitive — materialize skips it
|
|
1260
|
+
shim: (dispatch, spex) => ({
|
|
1261
|
+
content: piExtensionSource(dispatch, spex),
|
|
1262
|
+
cmd: (e: string) => `SPEX='${spex}' bash ${dispatch} pi ${e}`, // what the extension actually spawns, for parity with buildShim
|
|
1263
|
+
}),
|
|
1264
|
+
writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
|
|
1265
|
+
removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
|
|
1266
|
+
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
1267
|
+
slashCommands: piSlashCommands,
|
|
1268
|
+
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
1269
|
+
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
1270
|
+
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
1271
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1272
|
+
cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
|
|
1273
|
+
// reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
|
|
1274
|
+
// LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
|
|
1275
|
+
resumeArg: (rec) => `--session ${rec.session}`,
|
|
1276
|
+
}
|
|
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
|
+
|
|
1299
|
+
export const opencodeHarness: Harness = {
|
|
1300
|
+
id: 'opencode',
|
|
1301
|
+
headless: false,
|
|
1302
|
+
messageStream: false,
|
|
1303
|
+
events: OPENCODE_EVENTS,
|
|
1304
|
+
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
1305
|
+
// socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
|
|
1306
|
+
// parse-confirmed write) and socket-listener liveness are reused verbatim — no opencode transport code.
|
|
1307
|
+
ownsRendezvous: true,
|
|
1308
|
+
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
1309
|
+
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
1310
|
+
baseCmd: opencodeBaseCmd,
|
|
1311
|
+
sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
|
|
1312
|
+
// opencode exports NO per-session env var to its tool subprocesses (probed, 1.18.3). Identity flows through
|
|
1313
|
+
// the launch-injected SPEXCODE_SESSION_ID — honest here because each opencode TUI is a per-session process
|
|
1314
|
+
// (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
|
|
1315
|
+
// SPEXCODE_SESSION_ID tier resolves the record.
|
|
1316
|
+
sessionEnvVar: 'OPENCODE_SESSION_ID',
|
|
1317
|
+
// the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
|
|
1318
|
+
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
1319
|
+
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
1320
|
+
worktreeHookAnchor: () => null,
|
|
1321
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
|
|
1322
|
+
skillDir: (proj) => join(proj, '.opencode', 'skills'),
|
|
1323
|
+
agentDir: (proj) => join(proj, '.opencode', 'agents'),
|
|
1324
|
+
// content = the plugin source; cmd = the SAME per-event command the plugin bakes into dispatch calls, so
|
|
1325
|
+
// any consumer that hashes/inspects commands sees one truth (trust is a no-op here regardless).
|
|
1326
|
+
shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
|
|
1327
|
+
writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
|
|
1328
|
+
removeTrust: () => { /* nothing was written */ },
|
|
1329
|
+
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
1330
|
+
slashCommands: opencodeSlashCommands,
|
|
1331
|
+
// online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
|
|
1332
|
+
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|
|
1333
|
+
// load still reads honestly from the process signal instead of a false offline.
|
|
1334
|
+
liveness: (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
|
|
1335
|
+
(tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline'),
|
|
1336
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1337
|
+
cleanupRuntime: (rec) => { try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ } },
|
|
1338
|
+
// owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
|
|
1339
|
+
// SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
|
|
1340
|
+
// which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
|
|
1341
|
+
// is: a NEW launch's tail is always ONE single-quoted prompt arg, never a literal marker.
|
|
1342
|
+
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
|
|
1343
|
+
}
|
|
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
|
+
|
|
1124
1369
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
1125
|
-
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
|
|
1370
|
+
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness]
|
|
1126
1371
|
|
|
1127
1372
|
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
1128
1373
|
export const defaultHarness: Harness = claudeHarness
|
|
@@ -1135,23 +1380,35 @@ export function harnessById(id: string): Harness {
|
|
|
1135
1380
|
}
|
|
1136
1381
|
|
|
1137
1382
|
// --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
|
|
1138
|
-
// a launcher = a `{ harness, cmd }`
|
|
1139
|
-
// `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
|
|
1140
|
-
// regular command path), so they are edited like any other. harness defaults to claude.
|
|
1141
|
-
// fail-loud on an unknown name (a session must never silently launch under the wrong
|
|
1142
|
-
// harness id. There is NO env-derived built-in fallback:
|
|
1143
|
-
|
|
1383
|
+
// a launcher = a `{ harness, cmd }` entry in spexcode.json's `sessions.launchers`, keyed by a
|
|
1384
|
+
// human-chosen name. `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
|
|
1385
|
+
// launchers (with the regular command path), so they are edited like any other. harness defaults to claude.
|
|
1386
|
+
// resolveLauncher throws fail-loud on an unknown name (a session must never silently launch under the wrong
|
|
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 }
|
|
1144
1390
|
export type LauncherDefault = { default: string | null; error: string | null }
|
|
1145
1391
|
|
|
1146
|
-
// the configured named launchers from spexcode.json, as a stable name-sorted list (for
|
|
1147
|
-
//
|
|
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.
|
|
1148
1395
|
export function launcherList(root = mainCheckout()): Launcher[] {
|
|
1149
1396
|
const m = readConfig(root).sessions?.launchers || {}
|
|
1150
1397
|
return Object.keys(m)
|
|
1151
|
-
.map((name) =>
|
|
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
|
+
})
|
|
1152
1402
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1153
1403
|
}
|
|
1154
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
|
+
|
|
1155
1412
|
export const MISSING_DEFAULT_LAUNCHER_ERROR =
|
|
1156
1413
|
'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
|
|
1157
1414
|
|
|
@@ -1178,7 +1435,6 @@ export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
|
|
|
1178
1435
|
const l = readConfig(root).sessions?.launchers?.[name]
|
|
1179
1436
|
if (!l) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
|
|
1180
1437
|
if (!l.cmd) throw new Error(`launcher '${name}' is missing cmd`)
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
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 }
|
|
1184
1440
|
}
|