spexcode 0.4.0 → 0.4.3
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 +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -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 +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- 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 +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- 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/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -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 +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- 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 +47 -24
- 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 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → 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 +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- 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 +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- 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 +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.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 +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
package/spec-cli/src/harness.ts
CHANGED
|
@@ -6,7 +6,9 @@ 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'
|
|
10
12
|
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
11
13
|
import { git } from './git.js'
|
|
12
14
|
|
|
@@ -22,7 +24,7 @@ import { git } from './git.js'
|
|
|
22
24
|
// payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
|
|
23
25
|
// (materialize writes every harness's artifacts).
|
|
24
26
|
|
|
25
|
-
export type HarnessId = 'claude' | 'codex'
|
|
27
|
+
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi'
|
|
26
28
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
|
|
27
29
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
28
30
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
@@ -54,7 +56,6 @@ export interface Harness {
|
|
|
54
56
|
// instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
|
|
55
57
|
// is data on the adapter, not an `if (codex)` in sessions.ts.
|
|
56
58
|
readonly paneTitleIsSelfSummary: boolean
|
|
57
|
-
|
|
58
59
|
// --- launch / sessionId ---
|
|
59
60
|
// the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
|
|
60
61
|
// visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
|
|
@@ -101,16 +102,18 @@ export interface Harness {
|
|
|
101
102
|
// ONLY place agent-surface divergence lives (the skillDir analog). Claude reads .claude/agents/<name>.md;
|
|
102
103
|
// Codex has no file-discovered agent-definition primitive, so it returns null and materialize skips it.
|
|
103
104
|
agentDir(proj: string): string | null
|
|
104
|
-
// the shim payload:
|
|
105
|
-
//
|
|
106
|
-
|
|
105
|
+
// the shim payload: `content` is whatever artifact THIS harness auto-discovers to wire every event to the
|
|
106
|
+
// dispatcher (harness id baked in) — a settings/hooks JSON for claude/codex, a generated event-bus PLUGIN
|
|
107
|
+
// for opencode, a generated TypeScript EXTENSION for pi — plus the per-event command string (shared with
|
|
108
|
+
// the trust writer so they hash identically).
|
|
109
|
+
shim(dispatch: string, spex: string): { content: string; cmd: (e: string) => string }
|
|
107
110
|
// make a dispatched/self-launched agent run the hooks with zero prompts. Codex writes PROJECT trust — and, on
|
|
108
111
|
// a binary without `--dangerously-bypass-hook-trust`, per-hook trusted_hash blocks — into the GLOBAL
|
|
109
112
|
// ~/.codex/config.toml (codex's security model: trust is global-only). PROJECT trust is UNCONDITIONAL: it
|
|
110
113
|
// ENABLES the project config layer so codex discovers our hooks at all, a tier bypass_hook_trust does NOT
|
|
111
114
|
// cover. Claude is a no-op (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim
|
|
112
115
|
// emitted.
|
|
113
|
-
writeTrust(proj: string, cmdFor: (e: string) => string):
|
|
116
|
+
writeTrust(proj: string, cmdFor: (e: string) => string): readonly string[]
|
|
114
117
|
|
|
115
118
|
// --- the `/` menu ---
|
|
116
119
|
// the slash-command list, computed the way THIS harness computes its own `/` menu.
|
|
@@ -133,13 +136,21 @@ export interface Harness {
|
|
|
133
136
|
// grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
|
|
134
137
|
liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
|
|
135
138
|
// deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
|
|
136
|
-
// control socket —
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
+
// control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
|
|
140
|
+
// before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
|
|
141
|
+
// expiry on a still-open connection is a busy-not-lost agent → optimistic ok (see replyViaSocket). codex:
|
|
142
|
+
// JSON-RPC on the same app-server WebSocket the
|
|
139
143
|
// visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
|
|
140
144
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
141
145
|
// Returns ok=false with a reason that propagates to the API.
|
|
142
146
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
147
|
+
// the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
|
|
148
|
+
// socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
|
|
149
|
+
// the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
|
|
150
|
+
// BEFORE delivering; absent on harnesses with no such state (codex delivery ignores the pane). claude: the
|
|
151
|
+
// TUI's sessions panel ("← for agents") enqueues an injected reply to the panel context and never drains it
|
|
152
|
+
// — verified live: parsed + enqueued, no dequeue, no turn, daemon silent.
|
|
153
|
+
deliveryBlockedBy?(paneText: string): string | null
|
|
143
154
|
// --- materialize: clean (the inverse of write — [[harness-select]] prunes a deselected harness) ---
|
|
144
155
|
// clean is the EXACT inverse of materialize's per-harness write: SURGICALLY remove ONLY SpexCode's own
|
|
145
156
|
// artifacts — the managed contract block (sentinels), the generated shim file, the trust block, and the
|
|
@@ -161,11 +172,11 @@ export interface Harness {
|
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
// a prompt-dispatch outcome. ok=true means delivery is confirmed at the layer that harness proves it: claude at
|
|
164
|
-
// the
|
|
165
|
-
//
|
|
166
|
-
// `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
|
|
167
|
-
// and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
168
|
-
// its existing importers.
|
|
175
|
+
// the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
|
|
176
|
+
// still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
|
|
177
|
+
// accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
|
|
178
|
+
// (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
179
|
+
// re-exports it for its existing importers.
|
|
169
180
|
export type DispatchResult = { ok: boolean; error?: string }
|
|
170
181
|
export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
|
|
171
182
|
// the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
|
|
@@ -252,50 +263,84 @@ function shQuote(s: string): string {
|
|
|
252
263
|
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
253
264
|
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
254
265
|
|
|
255
|
-
// @@@ replyViaSocket -
|
|
256
|
-
// `
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
266
|
+
// @@@ replyViaSocket - ATOMIC parse-confirmed delivery. The daemon is SINGLE-CONNECTION: a new connect
|
|
267
|
+
// `destroy()`s the previous socket, discarding any received-but-not-yet-parsed line with it — and our own
|
|
268
|
+
// `rendezvousListening` liveness probe IS such a connect, fired for every session on every board snapshot. So
|
|
269
|
+
// the previous optimistic write (return ok once the reply line flushed) LOST prompts whenever a probe landed in
|
|
270
|
+
// the write→parse window, a window that widens exactly when claude is busy mid-turn (field: dashboard messages
|
|
271
|
+
// recorded `sent` with no trace in the claude transcript; measured 2/10 lost under a 20ms probe hammer). The
|
|
272
|
+
// daemon parses a chunk's complete lines in ONE synchronous loop, so writing `{type:reply}` + `{type:repaint}`
|
|
273
|
+
// as ONE chunk makes the pair indivisible — a kick loses BOTH or NEITHER — and the outcome decidable from this
|
|
274
|
+
// connection alone:
|
|
275
|
+
// `repaint-done` arrives → the reply line before it was parsed (in-order barrier) → ok, CONFIRMED.
|
|
276
|
+
// 'close' before it → the chunk was never parsed (kicked by a concurrent connect) → resolve kicked:true
|
|
277
|
+
// so deliverViaRendezvous RESENDS — a proven loss, so the retry cannot duplicate.
|
|
278
|
+
// wall expires, conn open → a busy event loop is DELAYING, not losing (the 2500ms-wall lesson: ack absence is
|
|
279
|
+
// NOT non-delivery) → ok, OPTIMISTIC — never a false failure on a busy worker.
|
|
280
|
+
// `reply-rejected`/`auth-rejected`/`shutting-down` → loud failure, not retried.
|
|
281
|
+
// Other daemon lines (heartbeat, state patches) are ignored. Never throws.
|
|
282
|
+
type ReplyOutcome = DispatchResult & { kicked?: boolean }
|
|
283
|
+
function replyViaSocket(sock: string, text: string, wallMs = 10_000): Promise<ReplyOutcome> {
|
|
270
284
|
return new Promise((resolve) => {
|
|
271
285
|
let settled = false
|
|
272
286
|
let c: ReturnType<typeof createConnection>
|
|
273
|
-
const done = (r:
|
|
287
|
+
const done = (r: ReplyOutcome) => {
|
|
274
288
|
if (settled) return
|
|
275
289
|
settled = true
|
|
290
|
+
clearTimeout(wall)
|
|
276
291
|
try { c?.destroy() } catch { /* */ }
|
|
277
292
|
resolve(r)
|
|
278
293
|
}
|
|
294
|
+
const wall = setTimeout(() => done({ ok: true }), wallMs)
|
|
279
295
|
try {
|
|
280
296
|
c = createConnection({ path: sock })
|
|
281
297
|
} catch (e) {
|
|
282
298
|
done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
|
|
283
299
|
return
|
|
284
300
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
301
|
+
// ECONNRESET/EPIPE are the KICK surfacing as an error: the daemon destroy()s the previous connection the
|
|
302
|
+
// moment a new one connects, and destroying a socket with OUR chunk still unread raises RST — whereas a
|
|
303
|
+
// parsed chunk answers repaint-done (readable even after a later close) before any clean FIN. So both codes
|
|
304
|
+
// PROVE the chunk was never parsed → retryable, same as the clean pre-parse close. ECONNREFUSED/ENOENT
|
|
305
|
+
// (daemon gone) stay loud.
|
|
306
|
+
c.on('error', (e: NodeJS.ErrnoException) => {
|
|
307
|
+
const code = e?.code || String(e)
|
|
308
|
+
const kicked = code === 'ECONNRESET' || code === 'EPIPE'
|
|
309
|
+
done({ ok: false, ...(kicked ? { kicked } : {}), error: `rendezvous socket error: ${code} — prompt NOT delivered` })
|
|
310
|
+
})
|
|
311
|
+
c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection was closed before the daemon parsed the prompt (kicked by a concurrent connect)' }))
|
|
312
|
+
c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
|
|
313
|
+
let buf = ''
|
|
314
|
+
c.on('data', (d) => {
|
|
315
|
+
buf += d.toString('utf8')
|
|
316
|
+
let nl
|
|
317
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
318
|
+
const line = buf.slice(0, nl)
|
|
319
|
+
buf = buf.slice(nl + 1)
|
|
320
|
+
let type = ''
|
|
321
|
+
try { type = (JSON.parse(line) as { type?: string })?.type ?? '' } catch { continue }
|
|
322
|
+
if (type === 'repaint-done') return done({ ok: true })
|
|
323
|
+
if (type === 'reply-rejected' || type === 'auth-rejected') return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` })
|
|
324
|
+
if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' })
|
|
325
|
+
}
|
|
326
|
+
})
|
|
290
327
|
})
|
|
291
328
|
}
|
|
292
329
|
// claude's deliver: the pre-write LIVENESS gate — fail loud BEFORE attempting the socket if it isn't there (a
|
|
293
|
-
// clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent
|
|
294
|
-
// Then
|
|
295
|
-
|
|
330
|
+
// clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent
|
|
331
|
+
// alive). Then the atomic parse-confirmed write; a KICKED outcome is a proven whole-chunk loss, so it resends
|
|
332
|
+
// (bounded attempts + jitter so re-collision with the probe cadence is unlikely); exhausted retries fail loud.
|
|
333
|
+
const DELIVER_ATTEMPTS = 3
|
|
334
|
+
export async function deliverViaRendezvous(id: string, text: string, wallMs?: number): Promise<DispatchResult> {
|
|
296
335
|
const sock = rvSock(id)
|
|
297
|
-
if (!existsSync(sock)) return
|
|
298
|
-
|
|
336
|
+
if (!existsSync(sock)) return { ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` }
|
|
337
|
+
let last: ReplyOutcome = { ok: false, error: 'not attempted' }
|
|
338
|
+
for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt++) {
|
|
339
|
+
last = await replyViaSocket(sock, text, wallMs)
|
|
340
|
+
if (last.ok || !last.kicked) return { ok: last.ok, ...(last.error ? { error: last.error } : {}) }
|
|
341
|
+
await new Promise((r) => setTimeout(r, 60 + Math.random() * 140))
|
|
342
|
+
}
|
|
343
|
+
return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
|
|
299
344
|
}
|
|
300
345
|
|
|
301
346
|
type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
|
|
@@ -379,7 +424,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
|
|
|
379
424
|
bypassProbe.set(binary, ok)
|
|
380
425
|
return ok
|
|
381
426
|
}
|
|
382
|
-
export function codexLaunchCommand(_id: string, codexCmd = 'codex
|
|
427
|
+
export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
|
|
383
428
|
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
384
429
|
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
385
430
|
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
@@ -714,13 +759,18 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
|
|
|
714
759
|
// rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
|
|
715
760
|
// found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
|
|
716
761
|
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
|
|
717
|
-
// does a rollout file for this thread id exist yet? Rollouts are grouped by date
|
|
718
|
-
//
|
|
762
|
+
// does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
|
|
763
|
+
// (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
|
|
764
|
+
// the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
|
|
765
|
+
// dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
|
|
766
|
+
// every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
|
|
767
|
+
// "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
|
|
719
768
|
export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
|
|
720
769
|
const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
770
|
+
for (const y of kids(root)) for (const m of kids(join(root, y))) for (const d of kids(join(root, y, m))) {
|
|
771
|
+
if (kids(join(root, y, m, d)).some((f) => f.includes(threadId))) return true
|
|
772
|
+
}
|
|
773
|
+
return false
|
|
724
774
|
}
|
|
725
775
|
// poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
|
|
726
776
|
// caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
|
|
@@ -804,11 +854,11 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
|
|
|
804
854
|
// the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
|
|
805
855
|
// baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
|
|
806
856
|
// the cli-needing handlers.
|
|
807
|
-
function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): {
|
|
857
|
+
function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { content: string; cmd: (e: string) => string } {
|
|
808
858
|
const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
|
|
809
859
|
const hooks: Record<string, unknown> = {}
|
|
810
860
|
for (const e of events) hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }]
|
|
811
|
-
return {
|
|
861
|
+
return { content: JSON.stringify({ hooks }, null, 2), cmd }
|
|
812
862
|
}
|
|
813
863
|
|
|
814
864
|
// ---------------------------------------------------------------------------------------------------------
|
|
@@ -867,7 +917,7 @@ function stripCodexTrustFor(cur: string, proj: string, hooksJson: string): strin
|
|
|
867
917
|
// leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
|
|
868
918
|
// Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
|
|
869
919
|
// (`events` may be empty for a trust-only stamp in tests.)
|
|
870
|
-
export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string):
|
|
920
|
+
export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): string {
|
|
871
921
|
const home = process.env.CODEX_HOME || join(homedir(), '.codex')
|
|
872
922
|
const file = join(home, 'config.toml')
|
|
873
923
|
const hooksJson = join(proj, '.codex', 'hooks.json')
|
|
@@ -880,6 +930,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
|
|
|
880
930
|
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
|
|
881
931
|
if (!existsSync(home)) mkdirSync(home, { recursive: true })
|
|
882
932
|
writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
|
|
933
|
+
return file
|
|
883
934
|
}
|
|
884
935
|
|
|
885
936
|
// the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
|
|
@@ -944,7 +995,9 @@ function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
|
|
|
944
995
|
// (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
|
|
945
996
|
// may not yet contain codex — as 'starting', not 'offline'.
|
|
946
997
|
const CODEXISH = /^(codex|node)/i // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
|
|
947
|
-
|
|
998
|
+
// the shared descendant-tree walk: does a process matching `re` live BELOW the pane pid? (The pane pid itself
|
|
999
|
+
// is the shell, so descendants only.) Pure over the caller's one ps snapshot.
|
|
1000
|
+
function paneTreeRuns(pane: PaneProbe | undefined, re: RegExp): boolean {
|
|
948
1001
|
if (!pane?.panePid || !pane.procs?.size) return false
|
|
949
1002
|
const kids = new Map<number, number[]>()
|
|
950
1003
|
for (const [pid, p] of pane.procs) {
|
|
@@ -954,24 +1007,75 @@ export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
|
|
|
954
1007
|
while (stack.length) {
|
|
955
1008
|
const pid = stack.pop()!
|
|
956
1009
|
const comm = pane.procs.get(pid)?.comm ?? ''
|
|
957
|
-
if (
|
|
1010
|
+
if (re.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
|
|
958
1011
|
const c = kids.get(pid); if (c) stack.push(...c)
|
|
959
1012
|
}
|
|
960
1013
|
return false
|
|
961
1014
|
}
|
|
1015
|
+
export function paneTreeRunsCodex(pane?: PaneProbe): boolean { return paneTreeRuns(pane, CODEXISH) }
|
|
1016
|
+
|
|
1017
|
+
// ONE whole-box pid→(ppid, comm) snapshot (a single `ps` spawn) — the table paneTreeRuns walks. Owned here
|
|
1018
|
+
// (beside its consumers) and shared with sessions.ts's liveSnapshot, so the two probe layers can never parse
|
|
1019
|
+
// ps differently. A failed/timed-out ps returns an empty table: the callers read that as not-provably-running.
|
|
1020
|
+
export async function procSnapshot(timeoutMs = 4000): Promise<ProcTable> {
|
|
1021
|
+
const t: ProcTable = new Map()
|
|
1022
|
+
let out = ''
|
|
1023
|
+
try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: timeoutMs, killSignal: 'SIGKILL' })) } catch { return t }
|
|
1024
|
+
for (const line of out.split('\n')) {
|
|
1025
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
|
|
1026
|
+
if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
|
|
1027
|
+
}
|
|
1028
|
+
return t
|
|
1029
|
+
}
|
|
962
1030
|
|
|
963
1031
|
// ---------------------------------------------------------------------------------------------------------
|
|
964
1032
|
// the two implementations.
|
|
965
1033
|
|
|
966
1034
|
const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
|
|
967
1035
|
const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
1036
|
+
// the five claude-shaped events pi's generated extension SYNTHESIZES from its own lifecycle (session_start →
|
|
1037
|
+
// SessionStart, input → UserPromptSubmit, tool_call → PreToolUse, tool_result → PostToolUse, agent_end +
|
|
1038
|
+
// agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
|
|
1039
|
+
// real gap as codex.
|
|
1040
|
+
const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
968
1041
|
|
|
969
1042
|
// the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
|
|
970
|
-
// launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The
|
|
971
|
-
// the fallback for a truly-old record with NO pinned cmd and NO launcher name —
|
|
972
|
-
//
|
|
973
|
-
|
|
974
|
-
const
|
|
1043
|
+
// launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
|
|
1044
|
+
// the fallback for a truly-old record with NO pinned cmd and NO launcher name — compatibility must preserve
|
|
1045
|
+
// the harness's normal permission model, never silently introduce an automatic-permission flag. There is no
|
|
1046
|
+
// env/config-field resolution because launchers are ordinary named config entries ([[launcher-select]]).
|
|
1047
|
+
const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
|
|
1048
|
+
const codexBaseCmd = (cmd?: string) => cmd || 'codex'
|
|
1049
|
+
const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
|
|
1050
|
+
const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
|
|
1051
|
+
|
|
1052
|
+
// @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
|
|
1053
|
+
// opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
|
|
1054
|
+
// prompt arg (a NEW launch → `--prompt`), or a resume marker from opencodeHarness.resumeArg: `--resume <id>`
|
|
1055
|
+
// re-attaches the owned opencode session (`--session <id>`, the SAME conversation), `--continue` re-attaches
|
|
1056
|
+
// the worktree's last session when no id was ever captured (the plugin failed before its first event). A new
|
|
1057
|
+
// launch's tail can never BE a literal marker (it's one quoted prompt), so the branch is unambiguous.
|
|
1058
|
+
export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
|
|
1059
|
+
const script = [
|
|
1060
|
+
`if [ "\${1:-}" = "--resume" ]; then`,
|
|
1061
|
+
// the marker carries the owned session id — export it so the plugin can seed rootSession at load: a
|
|
1062
|
+
// resumed session re-fires NO bus event until poked, so without this the rendezvous daemon rejects
|
|
1063
|
+
// every delivery (resume-continuity A-side: continuity ✓, steerability ✗).
|
|
1064
|
+
` export SPEXCODE_OPENCODE_RESUME_ID="$2"`,
|
|
1065
|
+
` exec ${opencodeCmd} --session "$2"`,
|
|
1066
|
+
`elif [ "\${1:-}" = "--continue" ]; then`,
|
|
1067
|
+
// no owned id to seed — mark the continue-resume so the plugin knows to ask the SDK for the
|
|
1068
|
+
// reattached session (scoped to this marker so a FRESH launch can never adopt a stale session).
|
|
1069
|
+
` export SPEXCODE_OPENCODE_CONTINUE=1`,
|
|
1070
|
+
` exec ${opencodeCmd} --continue`,
|
|
1071
|
+
`elif [ -n "\${1:-}" ]; then`,
|
|
1072
|
+
` exec ${opencodeCmd} --prompt "$1"`,
|
|
1073
|
+
`else`,
|
|
1074
|
+
` exec ${opencodeCmd}`,
|
|
1075
|
+
`fi`,
|
|
1076
|
+
].join('\n')
|
|
1077
|
+
return `bash -lc ${shQuote(script)} spexcode-opencode`
|
|
1078
|
+
}
|
|
975
1079
|
|
|
976
1080
|
export const claudeHarness: Harness = {
|
|
977
1081
|
id: 'claude',
|
|
@@ -988,7 +1092,7 @@ export const claudeHarness: Harness = {
|
|
|
988
1092
|
skillDir: (proj) => join(proj, '.claude', 'skills'),
|
|
989
1093
|
agentDir: (proj) => join(proj, '.claude', 'agents'),
|
|
990
1094
|
shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
|
|
991
|
-
writeTrust: () =>
|
|
1095
|
+
writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
|
|
992
1096
|
removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
|
|
993
1097
|
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
994
1098
|
slashCommands: claudeSlashCommands,
|
|
@@ -997,6 +1101,15 @@ export const claudeHarness: Harness = {
|
|
|
997
1101
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
998
1102
|
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
999
1103
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1104
|
+
// the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
|
|
1105
|
+
// and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
|
|
1106
|
+
// the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
|
|
1107
|
+
// strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
|
|
1108
|
+
// across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
|
|
1109
|
+
deliveryBlockedBy: (paneText) =>
|
|
1110
|
+
paneText.includes('describe a task for a new session') || (paneText.includes('enter to return') && paneText.includes('space to reply'))
|
|
1111
|
+
? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
|
|
1112
|
+
: null,
|
|
1000
1113
|
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
1001
1114
|
}
|
|
1002
1115
|
|
|
@@ -1046,7 +1159,7 @@ export const codexHarness: Harness = {
|
|
|
1046
1159
|
// the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
|
|
1047
1160
|
// as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
|
|
1048
1161
|
// thread still runs the hooks); it does not REPLACE the hashes here.
|
|
1049
|
-
writeTrust: (proj, cmdFor) => writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor),
|
|
1162
|
+
writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
|
|
1050
1163
|
// trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
|
|
1051
1164
|
removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
|
|
1052
1165
|
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
@@ -1070,8 +1183,93 @@ export const codexHarness: Harness = {
|
|
|
1070
1183
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
1071
1184
|
}
|
|
1072
1185
|
|
|
1186
|
+
// @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
|
|
1187
|
+
// the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
|
|
1188
|
+
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
1189
|
+
// hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
|
|
1190
|
+
// extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
|
|
1191
|
+
// dispatch.sh AND binds this session's rendezvous socket itself (sessions.ts already exports
|
|
1192
|
+
// CLAUDE_BG_RENDEZVOUS_SOCK to every ownsRendezvous launch) speaking the reclaude line protocol — so
|
|
1193
|
+
// deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
|
|
1194
|
+
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
1195
|
+
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
1196
|
+
// one-run defence. See pi-harness.ts for the extension source + trust mechanics.
|
|
1197
|
+
export const piHarness: Harness = {
|
|
1198
|
+
id: 'pi',
|
|
1199
|
+
events: PI_EVENTS,
|
|
1200
|
+
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
1201
|
+
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
1202
|
+
launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
|
|
1203
|
+
baseCmd: piBaseCmd,
|
|
1204
|
+
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
1205
|
+
sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
|
|
1206
|
+
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
1207
|
+
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
1208
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
1209
|
+
skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
|
|
1210
|
+
agentDir: () => null, // pi has no file-discovered sub-agent primitive — materialize skips it
|
|
1211
|
+
shim: (dispatch, spex) => ({
|
|
1212
|
+
content: piExtensionSource(dispatch, spex),
|
|
1213
|
+
cmd: (e: string) => `SPEX='${spex}' bash ${dispatch} pi ${e}`, // what the extension actually spawns, for parity with buildShim
|
|
1214
|
+
}),
|
|
1215
|
+
writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
|
|
1216
|
+
removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
|
|
1217
|
+
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
1218
|
+
slashCommands: piSlashCommands,
|
|
1219
|
+
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
1220
|
+
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
1221
|
+
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
1222
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1223
|
+
// reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
|
|
1224
|
+
// LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
|
|
1225
|
+
resumeArg: (rec) => `--session ${rec.session}`,
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
export const opencodeHarness: Harness = {
|
|
1229
|
+
id: 'opencode',
|
|
1230
|
+
events: OPENCODE_EVENTS,
|
|
1231
|
+
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
1232
|
+
// socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
|
|
1233
|
+
// parse-confirmed write) and socket-listener liveness are reused verbatim — no opencode transport code.
|
|
1234
|
+
ownsRendezvous: true,
|
|
1235
|
+
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
1236
|
+
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
1237
|
+
baseCmd: opencodeBaseCmd,
|
|
1238
|
+
sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
|
|
1239
|
+
// opencode exports NO per-session env var to its tool subprocesses (probed, 1.18.3). Identity flows through
|
|
1240
|
+
// the launch-injected SPEXCODE_SESSION_ID — honest here because each opencode TUI is a per-session process
|
|
1241
|
+
// (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
|
|
1242
|
+
// SPEXCODE_SESSION_ID tier resolves the record.
|
|
1243
|
+
sessionEnvVar: 'OPENCODE_SESSION_ID',
|
|
1244
|
+
// the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
|
|
1245
|
+
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
1246
|
+
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
1247
|
+
worktreeHookAnchor: () => null,
|
|
1248
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
|
|
1249
|
+
skillDir: (proj) => join(proj, '.opencode', 'skills'),
|
|
1250
|
+
agentDir: (proj) => join(proj, '.opencode', 'agents'),
|
|
1251
|
+
// content = the plugin source; cmd = the SAME per-event command the plugin bakes into dispatch calls, so
|
|
1252
|
+
// any consumer that hashes/inspects commands sees one truth (trust is a no-op here regardless).
|
|
1253
|
+
shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
|
|
1254
|
+
writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
|
|
1255
|
+
removeTrust: () => { /* nothing was written */ },
|
|
1256
|
+
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
1257
|
+
slashCommands: opencodeSlashCommands,
|
|
1258
|
+
// online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
|
|
1259
|
+
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|
|
1260
|
+
// load still reads honestly from the process signal instead of a false offline.
|
|
1261
|
+
liveness: (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
|
|
1262
|
+
(tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline'),
|
|
1263
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1264
|
+
// owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
|
|
1265
|
+
// SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
|
|
1266
|
+
// which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
|
|
1267
|
+
// is: a NEW launch's tail is always ONE single-quoted prompt arg, never a literal marker.
|
|
1268
|
+
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1073
1271
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
1074
|
-
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
|
|
1272
|
+
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness]
|
|
1075
1273
|
|
|
1076
1274
|
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
1077
1275
|
export const defaultHarness: Harness = claudeHarness
|
|
@@ -1084,16 +1282,17 @@ export function harnessById(id: string): Harness {
|
|
|
1084
1282
|
}
|
|
1085
1283
|
|
|
1086
1284
|
// --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
|
|
1087
|
-
// a launcher = a `{ harness, cmd }`
|
|
1088
|
-
// `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
|
|
1089
|
-
// regular command path), so they are edited like any other. harness defaults to claude.
|
|
1090
|
-
// fail-loud on an unknown name (a session must never silently launch under the wrong
|
|
1091
|
-
// harness id. There is NO env-derived built-in fallback: the dropdown lists exactly
|
|
1285
|
+
// a launcher = a `{ harness, cmd }` entry in spexcode.json's `sessions.launchers`, keyed by a
|
|
1286
|
+
// human-chosen name. `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
|
|
1287
|
+
// launchers (with the regular command path), so they are edited like any other. harness defaults to claude.
|
|
1288
|
+
// 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.
|
|
1092
1291
|
export type Launcher = { name: string; harness: string; cmd: string }
|
|
1093
1292
|
export type LauncherDefault = { default: string | null; error: string | null }
|
|
1094
1293
|
|
|
1095
|
-
// the configured named launchers from spexcode.json, as a stable name-sorted list (for the dashboard dropdown
|
|
1096
|
-
// the CLI). Picking a launcher is the ONLY launch choice; the old separate harness pick is gone.
|
|
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.
|
|
1097
1296
|
export function launcherList(root = mainCheckout()): Launcher[] {
|
|
1098
1297
|
const m = readConfig(root).sessions?.launchers || {}
|
|
1099
1298
|
return Object.keys(m)
|