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.
Files changed (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -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: the settings/hooks JSON binding every event the dispatcher (harness id baked in), and
105
- // the per-event command string (shared with the trust writer so they hash identically).
106
- shim(dispatch: string, spex: string): { json: string; cmd: (e: string) => string }
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): void
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 — OPTIMISTIC-after-liveness (loud failure when the socket is missing/dead or the write can't
137
- // flush; once the reply line flushes to a live socket it returns ok, without waiting for an application ack —
138
- // see replyViaSocket). codex: JSON-RPC on the same app-server WebSocket the
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 TRANSPORT/liveness layer (the reply line flushed to a live rendezvous socket see replyViaSocket for why
165
- // it is OPTIMISTIC-after-liveness, not a round-trip ack); codex at the application layer (the app-server accepted
166
- // `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route (non-2xx)
167
- // and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts re-exports it for
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 - OPTIMISTIC-after-liveness delivery: connect to the LIVE rendezvous socket and WRITE the
256
- // `{type:reply}\n` line; once that line FLUSHES to the socket with no immediate transport error, the reply is on
257
- // the wire and claude submits it the moment it yields the event loop so we return ok:true right there. We do
258
- // NOT wait for an application-level acceptance ack. The old code wrote a `{type:repaint}` probe after the reply
259
- // and waited up to 2500ms for a `{type:repaint-done}` to CONFIRM the reply was processed; the ordering barrier is
260
- // sound one-way (repaint-done delivered) but its ABSENCE is NOT proof of non-delivery a mid-turn/BUSY claude
261
- // can't answer the probe within the wall, so a message that actually landed reported a COMMON, misleading false
262
- // failure. The delivery confirmation therefore lives at the TRANSPORT/liveness layer, not the application layer:
263
- // deliverViaRendezvous already gates on the socket's existence (liveness), and here the FREE, INSTANT transport
264
- // signals a connect that throws, a socket 'error' (ECONNREFUSED / EPIPE), or a 'close' BEFORE the write flushes —
265
- // are still reported as real failures (they cost no waiting). What we knowingly give up is detection of the narrow
266
- // alive-but-wedged / reply-rejected / shutting-down cases: a rare silent drop, recoverable because the supervisor
267
- // sees the worker never transition. That tradeoff is the deliberate contract now see [[harness-adapter]].
268
- // Never throws.
269
- function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
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: DispatchResult) => {
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
- c.on('error', (e: NodeJS.ErrnoException) => done({ ok: false, error: `rendezvous socket connect failed: ${e?.code || String(e)}` }))
286
- c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was written' }))
287
- // write the reply line; the write callback fires once it flushes to the live socket optimistic ok. An
288
- // immediate transport 'error'/'close' beats the callback and reports the real failure (done is idempotent).
289
- c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n', () => done({ ok: true })))
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 alive).
294
- // Then inject the reply optimistically via replyViaSocket (no round-trip ack).
295
- function deliverViaRendezvous(id: string, text: string): Promise<DispatchResult> {
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 Promise.resolve({ ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` })
298
- return replyViaSocket(sock, text)
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 --yolo', serverCmd?: string, dir = runtimeRoot()): string {
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, so scan only the newest few
718
- // day-dirs (lexical order = chronological on zero-padded YYYY/MM/DD) — clock-agnostic and cheap on a big history.
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 days: string[] = []
722
- for (const y of kids(root)) { for (const m of kids(join(root, y))) { for (const d of kids(join(root, y, m))) { days.push(join(root, y, m, d)); if (days.length >= 3) break } if (days.length >= 3) break } if (days.length >= 3) break }
723
- return days.some((d) => kids(d).some((f) => f.includes(threadId)))
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): { json: string; cmd: (e: string) => 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 { json: JSON.stringify({ hooks }, null, 2), cmd }
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): void {
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
- export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
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 (CODEXISH.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
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 bare default is only
971
- // the fallback for a truly-old record with NO pinned cmd and NO launcher name — there is no env/config-field
972
- // resolution, because claude/codex are ordinary named launchers now ([[launcher-select]]), resolved by name.
973
- const claudeBaseCmd = (cmd?: string) => cmd || 'claude --dangerously-skip-permissions'
974
- const codexBaseCmd = (cmd?: string) => cmd || 'codex --yolo'
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: () => { /* Claude relies on folder-trust — nothing to write */ },
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 }` pair in spexcode.json's `sessions.launchers`, keyed by a human-chosen name.
1088
- // `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named launchers (with the
1089
- // regular command path), so they are edited like any other. harness defaults to claude. resolveLauncher throws
1090
- // fail-loud on an unknown name (a session must never silently launch under the wrong auth) and validates the
1091
- // harness id. There is NO env-derived built-in fallback: the dropdown lists exactly the config's real launchers.
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)