spexcode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/bin/spex.mjs +15 -0
  2. package/dashboard-dist/assets/index-B60MILFg.js +139 -0
  3. package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
  4. package/dashboard-dist/index.html +16 -0
  5. package/package.json +35 -0
  6. package/src/board.ts +119 -0
  7. package/src/cli.ts +487 -0
  8. package/src/client.ts +102 -0
  9. package/src/gateway.ts +241 -0
  10. package/src/git.ts +492 -0
  11. package/src/guide.ts +134 -0
  12. package/src/harness.ts +674 -0
  13. package/src/hooks.ts +41 -0
  14. package/src/index.ts +233 -0
  15. package/src/init.ts +120 -0
  16. package/src/layout.ts +246 -0
  17. package/src/lint.ts +206 -0
  18. package/src/login-page.ts +79 -0
  19. package/src/materialize.ts +85 -0
  20. package/src/pty-bridge.ts +235 -0
  21. package/src/ranker.ts +129 -0
  22. package/src/resilience.ts +41 -0
  23. package/src/search.bench.mjs +47 -0
  24. package/src/search.ts +24 -0
  25. package/src/self.ts +256 -0
  26. package/src/sessions.ts +1469 -0
  27. package/src/slash-commands.ts +242 -0
  28. package/src/specs.ts +331 -0
  29. package/src/supervise.ts +158 -0
  30. package/src/uploads.ts +31 -0
  31. package/templates/hooks/pre-commit +57 -0
  32. package/templates/hooks/prepare-commit-msg +14 -0
  33. package/templates/spec/project/.config/core/idle/idle.sh +15 -0
  34. package/templates/spec/project/.config/core/idle/spec.md +13 -0
  35. package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
  36. package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
  37. package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
  38. package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
  39. package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
  40. package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
  41. package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
  42. package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
  43. package/templates/spec/project/.config/core/spec.md +13 -0
  44. package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
  45. package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
  46. package/templates/spec/project/.config/extract/spec.md +60 -0
  47. package/templates/spec/project/.config/forge-link/spec.md +9 -0
  48. package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
  49. package/templates/spec/project/.config/regroup/spec.md +25 -0
  50. package/templates/spec/project/.config/scenario/spec.md +32 -0
  51. package/templates/spec/project/.config/spec.md +15 -0
  52. package/templates/spec/project/.config/supervisor/spec.md +8 -0
  53. package/templates/spec/project/.config/tidy/spec.md +25 -0
  54. package/templates/spec/project/spec.md +19 -0
  55. package/templates/spexcode.json +5 -0
package/src/harness.ts ADDED
@@ -0,0 +1,674 @@
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { createHash, randomBytes } from 'node:crypto'
5
+ import { createConnection, type Socket } from 'node:net'
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
10
+ import { runtimeRoot, mainCheckout } from './layout.js'
11
+
12
+ // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
13
+ // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
14
+ // (materialize, sessions, slash, the hook scripts) never branches on which harness it is — it resolves an
15
+ // adapter ONCE and calls it. The only `if (codex)` / `if (claude)` in the whole product is the detector that
16
+ // picks the adapter (here), plus its shell mirror in hooks/harness.sh (shell cannot import this module).
17
+ //
18
+ // DETECTION. There is no payload-sniffing: each adapter OWNS its shim, and the shim bakes the harness id as
19
+ // dispatch.sh's first argument (`bash <dispatch> <id> <Event>`). dispatch.sh exports SPEXCODE_HARNESS, so a
20
+ // hook subprocess learns its harness deterministically from the shim that wired it — never from guessing the
21
+ // payload shape. On the TS side the harness is the launcher's choice (the dashboard launches `defaultHarness`)
22
+ // or ALL adapters at once (materialize renders every harness's artifacts).
23
+
24
+ export type HarnessId = 'claude' | 'codex'
25
+ export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
26
+
27
+ export interface Harness {
28
+ readonly id: HarnessId
29
+ // the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
30
+ // Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
31
+ // idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
32
+ // not a TODO. It binds only the five it actually fires (see CODEX_EVENTS).
33
+ readonly events: readonly string[]
34
+ // whether the harness's agent opens a reclaude rendezvous control socket. Claude does; Codex has no such
35
+ // daemon and uses its app-server JSON-RPC control plane instead.
36
+ readonly ownsRendezvous: boolean
37
+ // whether this harness's tmux pane_title is the agent's OWN live task self-summary (so the board headline
38
+ // may derive from it — see [[session-activity]]). Claude continuously writes a one-line task summary into
39
+ // its OSC title → true. Codex sets the pane title to a spinner glyph + the cwd basename (the worktree FOLDER
40
+ // name), which is NOT a self-summary → false, so its headline falls through to the launch-prompt preview
41
+ // instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
42
+ // is data on the adapter, not an `if (codex)` in sessions.ts.
43
+ readonly paneTitleIsSelfSummary: boolean
44
+
45
+ // --- launch / sessionId ---
46
+ // the base agent command (env-overridable for tests). Claude: `claude …`; Codex starts a project-scoped
47
+ // app-server and launches the visible TUI with `--remote` pointed at it.
48
+ launchCmd(id: string, runtimeDir?: string): string
49
+ // the flag that pins the session id at launch. Claude lets the caller choose (`--session-id <id>`); Codex
50
+ // assigns its own, so there is nothing to pass (the id is captured/resumed afterwards).
51
+ sessionIdArg(id: string): string
52
+ // the env var the agent's OWN process carries so its `spex …` calls know their session id.
53
+ readonly sessionEnvVar: string
54
+
55
+ // --- materialize: shim + contract + trust ([[harness-delivery]]) ---
56
+ // the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
57
+ shimFile(proj: string): string
58
+ // the contract file(s) the `surface: system` block is folded into. Claude: ./CLAUDE.md; Codex: ONLY ./AGENTS.md.
59
+ contractFiles(proj: string): string[]
60
+ // the dir this harness auto-discovers skills from, or null if it has no skill primitive — the ONLY place skill-surface divergence lives.
61
+ skillDir(proj: string): string | null
62
+ // the shim payload: the settings/hooks JSON binding every event → the dispatcher (harness id baked in), and
63
+ // the per-event command string (shared with the trust writer so they hash identically).
64
+ shim(dispatch: string, spex: string): { json: string; cmd: (e: string) => string }
65
+ // make a user-self-launched agent run the hooks with zero prompts. Codex writes a deterministic trusted_hash
66
+ // into the GLOBAL ~/.codex/config.toml (codex's security model: trust is global-only); Claude is a no-op
67
+ // (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim emitted.
68
+ writeTrust(proj: string, cmdFor: (e: string) => string): void
69
+
70
+ // --- the `/` menu ---
71
+ // the slash-command list, computed the way THIS harness computes its own `/` menu.
72
+ slashCommands(): SlashCommand[]
73
+
74
+ // --- runtime: liveness + prompt delivery ([[harness-delivery]]) ---
75
+ // is this session's agent process up? The caller passes in the tmux-window presence it already computed
76
+ // (one tmux snapshot for the whole list — see sessions.ts liveTmux), and the adapter adds ONLY its own
77
+ // channel check. claude: online iff the tmux window is up AND its reclaude rendezvous socket exists (the
78
+ // socket is the truth claude is alive — the pane command is the wrapper/shell while claude runs as a child).
79
+ // codex: online iff the tmux window is up AND the project-scoped app-server socket exists (one socket per
80
+ // PROJECT, shared by every worktree's thread); the per-session window presence is the session signal, the
81
+ // socket is a project control plane, not session identity.
82
+ liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string): 'online' | 'offline'
83
+ // deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
84
+ // control socket, which injects + submits the prompt and CONFIRMS the daemon accepted it (loud failure on a
85
+ // missing/dead socket — never a silent degradation). codex: JSON-RPC on the same app-server WebSocket the
86
+ // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
87
+ // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
88
+ // Returns ok=false with a reason that propagates to the API.
89
+ deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
90
+ // the relaunch tail reopen() hands launch() to bring the SAME work back up. claude resumes the same
91
+ // conversation (`--resume <id>`, the id we pinned at launch). codex's own thread id is un-pinnable on the
92
+ // launch flag, so the BACKEND owns it: it `thread/start`s the thread and stores the id at launch, so reopen
93
+ // resumes the SAME conversation via codex's own `resume <thread-id>` subcommand (the stored harnessSessionId,
94
+ // its rollout persisted on disk). Only a session whose thread id was never stored relaunches FRESH (empty
95
+ // tail) in the same worktree/record — there is nothing to resume.
96
+ resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
97
+ }
98
+
99
+ // a prompt-dispatch outcome. ok=true ONLY when delivery is CONFIRMED (claude: the daemon ACCEPTED the prompt;
100
+ // codex: app-server accepted `turn/start`). `error` carries a human-readable reason that propagates to the
101
+ // API route (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
102
+ // re-exports it for its existing importers.
103
+ export type DispatchResult = { ok: boolean; error?: string }
104
+ export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
105
+
106
+ // @@@ rendezvous control socket - claude's DETERMINISTIC, ONLY input path for PROMPTS to sessions WE launch.
107
+ // sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
108
+ // that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
109
+ // `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
110
+ // and Enters can't be corrupted the way `tmux send-keys` was. The path is uniquely derived from the session id,
111
+ // so we only ever address OUR OWN sockets (HARD ethics rule: never touch a session outside this product). It
112
+ // lives in tmpdir tied to the claude process, so no extra lifecycle. liveness reads its existence (present while
113
+ // claude is alive, gone once it exits); deliver writes to it. Exported because sessions.ts builds the launch env
114
+ // var from it and best-effort sweeps it on close — but the liveness/delivery USE is the adapter's, below.
115
+ export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
116
+ export const codexAppServerSock = (dir = process.env.SPEXCODE_CODEX_SOCKET_DIR || tmpdir()) => join(dir, 'codex-app-server.sock')
117
+ export const codexAppServerPid = (dir = process.env.SPEXCODE_CODEX_SOCKET_DIR || tmpdir()) => join(dir, 'codex-app-server.pid')
118
+
119
+ function shQuote(s: string): string {
120
+ return `'${s.replace(/'/g, `'\\''`)}'`
121
+ }
122
+
123
+ // the tsx + cli.ts invocation, baked into the codex launch script (mirrors materialize.ts's SPEX) so the
124
+ // launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
125
+ // exec's the visible TUI.
126
+ const PKG = fileURLToPath(new URL('..', import.meta.url))
127
+ const SPEX = `${join(PKG, 'node_modules', '.bin', 'tsx')} ${join(PKG, 'src', 'cli.ts')}`
128
+
129
+ const ACCEPT_TIMEOUT_MS = 2500
130
+ // @@@ replyViaSocket - inject `text` as a prompt AND confirm the daemon ACCEPTED it (not mere write-success,
131
+ // which is what silently masked dead dispatches before). The CLAUDE_BG_BACKEND=daemon rendezvous server sends
132
+ // NO ack for an accepted reply, so we confirm via an IN-ORDER round-trip: we write `{type:reply}\n{type:repaint}\n`.
133
+ // The daemon dispatches socket lines strictly in order and ENQUEUES the reply BEFORE it handles the repaint and
134
+ // answers `{type:repaint-done}` — so a `repaint-done` with NO preceding `reply-rejected` proves the reply was
135
+ // processed. `repaint` is auth-exempt and always answers, so it's a reliable probe even against a future daemon
136
+ // that gates `reply` behind auth (a gated reply emits `reply-rejected` FIRST). `reply-rejected`/`shutting-down`,
137
+ // a connect/socket error, an early close, or no confirmation within ACCEPT_TIMEOUT_MS ALL resolve to a loud
138
+ // failure with a specific reason. The forced repaint is a harmless redraw of the agent's OWN TUI. Never throws.
139
+ function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
140
+ return new Promise((resolve) => {
141
+ let settled = false, buf = ''
142
+ let c: ReturnType<typeof createConnection>
143
+ const done = (r: DispatchResult) => {
144
+ if (settled) return
145
+ settled = true
146
+ clearTimeout(timer)
147
+ try { c?.destroy() } catch { /* */ }
148
+ resolve(r)
149
+ }
150
+ const timer = setTimeout(
151
+ () => done({ ok: false, error: `rendezvous socket gave no acceptance confirmation within ${ACCEPT_TIMEOUT_MS}ms` }),
152
+ ACCEPT_TIMEOUT_MS,
153
+ )
154
+ try {
155
+ c = createConnection({ path: sock })
156
+ } catch (e) {
157
+ done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
158
+ return
159
+ }
160
+ c.on('error', (e: NodeJS.ErrnoException) => done({ ok: false, error: `rendezvous socket connect failed: ${e?.code || String(e)}` }))
161
+ c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was confirmed accepted' }))
162
+ c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
163
+ c.on('data', (chunk: Buffer) => {
164
+ buf += chunk.toString('utf8')
165
+ let i: number
166
+ while ((i = buf.indexOf('\n')) >= 0) {
167
+ const line = buf.slice(0, i); buf = buf.slice(i + 1)
168
+ if (!line) continue
169
+ let type: string | undefined
170
+ try { type = JSON.parse(line)?.type } catch { continue } // ignore any non-JSON noise on the wire
171
+ if (type === 'reply-rejected') return done({ ok: false, error: 'agent REJECTED the prompt (rendezvous reply-rejected — auth-gated daemon?)' })
172
+ if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt not accepted' })
173
+ if (type === 'repaint-done') return done({ ok: true }) // reply was enqueued in-order before this
174
+ // heartbeat / state / other frames → keep waiting for the decisive repaint-done or a rejection.
175
+ }
176
+ })
177
+ })
178
+ }
179
+ // claude's deliver: fail loud BEFORE attempting the socket if it isn't there (a clearer message than a raw
180
+ // connect error), exactly as the old sendKeys did, then inject + confirm via the rendezvous round-trip.
181
+ function deliverViaRendezvous(id: string, text: string): Promise<DispatchResult> {
182
+ const sock = rvSock(id)
183
+ 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` })
184
+ return replyViaSocket(sock, text)
185
+ }
186
+
187
+ type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
188
+
189
+ // The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
190
+ // 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
191
+ // `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
192
+ // it (that re-loads a thread the live TUI already owns). Instead `thread/loaded/list` PROVES the captured thread
193
+ // is the one the pane is showing, then `thread/read{includeTurns}` reveals whether a turn is in progress (and
194
+ // its id). The 4th, injecting message is CHOSEN from that read — see codexInjectMessage.
195
+ const codexTextInput = (text: string) => [{ type: 'text', text, text_elements: [] }]
196
+ export function codexHandshakeMessages(threadId: string): JsonRpc[] {
197
+ return [
198
+ {
199
+ id: 1,
200
+ method: 'initialize',
201
+ params: {
202
+ clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' },
203
+ capabilities: { experimentalApi: true, requestAttestation: false },
204
+ },
205
+ },
206
+ { method: 'initialized', params: {} },
207
+ { id: 2, method: 'thread/loaded/list', params: {} },
208
+ { id: 3, method: 'thread/read', params: { threadId, includeTurns: true } },
209
+ ]
210
+ }
211
+
212
+ // the message that injects `text`. STEER (turn/steer) when an active turn id is known — codex processes it
213
+ // WITHOUT waiting for the current turn to end (the human's "工具调用完就插入": injected the moment the running
214
+ // tool call returns), so a busy agent reacts mid-turn instead of queuing the message for after it stops.
215
+ // `TurnSteerParams` REQUIRES the live turn id as `expectedTurnId` (the server rejects a stale one) — so this is
216
+ // only sent with a turnId read live from the thread, never from SpexCode's session status. When the thread is
217
+ // idle (no active turn id), START a fresh turn (turn/start). `id` is parameterized so a steer that loses the
218
+ // expectedTurnId race (turn ended in the read→steer window) can retry as a turn/start with id 5.
219
+ export function codexInjectMessage(threadId: string, text: string, cwd: string | undefined, activeTurnId: string | null, id = 4): JsonRpc {
220
+ if (activeTurnId)
221
+ return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId } }
222
+ return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}) } }
223
+ }
224
+
225
+ // the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. With
226
+ // includeTurns the Thread carries its turns, each with a TurnStatus ("completed"|"interrupted"|"failed"|
227
+ // "inProgress"); the live turn is the `inProgress` one and its id is exactly what turn/steer's precondition needs.
228
+ export function activeTurnIdFromThread(readResult: unknown): string | null {
229
+ const thread = (readResult as { thread?: { turns?: Array<{ id?: string; status?: string }> } })?.thread
230
+ const turns = Array.isArray(thread?.turns) ? thread.turns : []
231
+ const active = turns.find((t) => t?.status === 'inProgress')
232
+ return active?.id ?? null
233
+ }
234
+
235
+ export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_CODEX_CMD || 'codex --yolo', serverCmd = process.env.SPEXCODE_CODEX_SERVER_CMD || 'codex', dir = process.env.SPEXCODE_CODEX_SOCKET_DIR || runtimeRoot()): string {
236
+ const sock = codexAppServerSock(dir)
237
+ const pid = codexAppServerPid(dir)
238
+ const log = join(dir, 'codex-app-server.log')
239
+ const lock = join(dir, 'codex-app-server.lock')
240
+ const script = [
241
+ `dir=${shQuote(dir)}`,
242
+ `sock=${shQuote(sock)}`,
243
+ `pid=${shQuote(pid)}`,
244
+ `log=${shQuote(log)}`,
245
+ `lock=${shQuote(lock)}`,
246
+ 'mkdir -p "$dir"',
247
+ '(',
248
+ ' flock 9',
249
+ ' if [ -S "$sock" ] && [ -s "$pid" ] && ! kill -0 "$(cat "$pid")" 2>/dev/null; then rm -f "$sock"; fi',
250
+ ' if [ ! -S "$sock" ]; then',
251
+ // 9>&- : do NOT let the long-lived app-server inherit fd 9 (the flock fd). An flock is held until
252
+ // EVERY fd on its open file description is closed; if the daemon keeps fd 9 open it pins the lock
253
+ // forever, so every later launcher blocks on `flock 9` and never reaches the thread-owning step
254
+ // (the pane stays at the shell, no TUI, no thread). </dev/null detaches its stdin from the pane so
255
+ // it can't fight the TUI for the tty.
256
+ ` ${serverCmd} app-server --listen unix://"$sock" >"$log" 2>&1 9>&- </dev/null &`,
257
+ ' echo $! > "$pid"',
258
+ ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
259
+ ' fi',
260
+ ') 9>"$lock"',
261
+ // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
262
+ // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
263
+ // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
264
+ // does thread/start { cwd = this worktree } on the shared per-project app-server, stores the new id on the
265
+ // governed record (SPEXCODE_SESSION_ID), and fires the tail as the FIRST turn, materializing the rollout.
266
+ // Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
267
+ // rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
268
+ // literal "--resume" marker — the discriminator is unambiguous.
269
+ `if [ "$1" = "--resume" ]; then`,
270
+ ` tid=$2`,
271
+ `else`,
272
+ ` tid=$(${SPEX} codex-launch "$sock" "$PWD" "$@")`,
273
+ `fi`,
274
+ `exec ${codexCmd} --remote unix://"$sock" resume "$tid"`,
275
+ ].join('\n')
276
+ return `bash -lc ${shQuote(script)} spexcode-codex`
277
+ }
278
+
279
+ function rpcError(e: unknown): string {
280
+ return String((e as Error)?.message || e)
281
+ }
282
+
283
+ // --- minimal RFC6455 client framing ------------------------------------------------------------------------
284
+ // The codex app-server `--listen unix://<sock>` transport is a WebSocket endpoint at path `/rpc` (the visible
285
+ // `codex --remote` TUI upgrades the very same way). So we speak WebSocket over the Unix socket — NOT a raw byte
286
+ // stream, and NOT `codex app-server proxy` (a dumb byte relay that performs no HTTP upgrade, so the server
287
+ // rejects its bytes as an invalid upgrade and closes — the old 502). One JSON-RPC message = one masked text
288
+ // frame; the server's frames come back unmasked. We only ever exchange small frames, so this is deliberately
289
+ // small: text + the control frames (ping→pong, close) we must honor, plus continuation reassembly for safety.
290
+ function encodeWsFrame(opcode: number, payload: Buffer): Buffer {
291
+ const len = payload.length
292
+ const mask = randomBytes(4)
293
+ let header: Buffer
294
+ if (len < 126) header = Buffer.from([0x80 | opcode, 0x80 | len])
295
+ else if (len < 65536) header = Buffer.from([0x80 | opcode, 0x80 | 126, (len >> 8) & 0xff, len & 0xff])
296
+ else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(len), 2) }
297
+ const masked = Buffer.alloc(len)
298
+ for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4]
299
+ return Buffer.concat([header, mask, masked])
300
+ }
301
+ const wsText = (s: string) => encodeWsFrame(0x1, Buffer.from(s, 'utf8'))
302
+
303
+ // Decode the unmasked server→client frames accumulated in `buf`, handing each complete text message to
304
+ // `onText`; honors ping→pong and a close. Shared by every app-server WS client here. Returns the (possibly
305
+ // shrunk) buffer + whether a close was seen, plus the running fragment state threaded back in on each call.
306
+ type FrameState = { buf: Buffer; fragOp: number; fragBuf: Buffer }
307
+ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => void): boolean {
308
+ for (;;) {
309
+ if (s.buf.length < 2) return false
310
+ const b0 = s.buf[0], b1 = s.buf[1], op = b0 & 0x0f, fin = (b0 & 0x80) !== 0, masked = (b1 & 0x80) !== 0
311
+ let len = b1 & 0x7f, off = 2
312
+ if (len === 126) { if (s.buf.length < 4) return false; len = s.buf.readUInt16BE(2); off = 4 }
313
+ else if (len === 127) { if (s.buf.length < 10) return false; len = Number(s.buf.readBigUInt64BE(2)); off = 10 }
314
+ const dataStart = off + (masked ? 4 : 0)
315
+ if (s.buf.length < dataStart + len) return false
316
+ let payload = s.buf.slice(dataStart, dataStart + len)
317
+ if (masked) { const mk = s.buf.slice(off, off + 4); const u = Buffer.alloc(len); for (let i = 0; i < len; i++) u[i] = payload[i] ^ mk[i % 4]; payload = u }
318
+ s.buf = s.buf.slice(dataStart + len)
319
+ if (op === 0x8) return true // close
320
+ if (op === 0x9) { conn.write(encodeWsFrame(0xa, payload)); continue } // ping → pong
321
+ if (op === 0xa) continue // pong
322
+ if (op === 0x0) s.fragBuf = Buffer.concat([s.fragBuf, payload]) // continuation
323
+ else { s.fragOp = op; s.fragBuf = payload }
324
+ if (fin) { if (s.fragOp === 0x1) onText(s.fragBuf.toString('utf8')); s.fragBuf = Buffer.alloc(0); s.fragOp = 0 }
325
+ }
326
+ }
327
+ const WS_UPGRADE = (key: string) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`
328
+ const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
329
+
330
+ // Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
331
+ // id at launch (codexStartThread → stored on the record), this is only the DELIVERY FALLBACK for a pre-existing
332
+ // session whose id was never stored: it returns the first loaded thread. On a shared per-project server several
333
+ // threads may be loaded, so it is no longer the deterministic capture path — the stored id is. Never throws.
334
+ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
335
+ return new Promise((resolve) => {
336
+ const conn: Socket = createConnection(sock)
337
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
338
+ let upgraded = false, settled = false
339
+ const done = (r: { ok: true; threadId: string } | { ok: false; error: string }) => {
340
+ if (settled) return
341
+ settled = true
342
+ clearTimeout(timer)
343
+ try { conn.destroy() } catch { /* */ }
344
+ resolve(r)
345
+ }
346
+ const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not list threads within 5000ms' }), 5000)
347
+ conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }))
348
+ conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/loaded/list was answered' }))
349
+ const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
350
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
351
+ const handle = (json: string) => {
352
+ let m: JsonRpc
353
+ try { m = JSON.parse(json) } catch { return }
354
+ if (m.error) return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
355
+ if (m.id === 1 && m.result) { send({ method: 'initialized', params: {} }); return send({ id: 2, method: 'thread/loaded/list', params: {} }) }
356
+ if (m.id === 2 && m.result) {
357
+ const data = (m.result as { data?: unknown }).data
358
+ const ids = Array.isArray(data) ? data.filter((x): x is string => typeof x === 'string') : []
359
+ return ids.length ? done({ ok: true, threadId: ids[0] }) : done({ ok: false, error: 'no loaded thread on the app-server socket yet (TUI still booting?)' })
360
+ }
361
+ }
362
+ conn.on('data', (chunk: Buffer) => {
363
+ fs.buf = Buffer.concat([fs.buf, chunk])
364
+ if (!upgraded) {
365
+ const i = fs.buf.indexOf('\r\n\r\n')
366
+ if (i < 0) return
367
+ const head = fs.buf.slice(0, i).toString('utf8')
368
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
369
+ upgraded = true
370
+ fs.buf = fs.buf.slice(i + 4)
371
+ send(wsInitialize)
372
+ }
373
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: 'codex app-server sent a WebSocket close before thread/loaded/list was confirmed' })
374
+ })
375
+ })
376
+ }
377
+
378
+ // @@@ codexStartThread - the BACKEND owns the thread. On the shared PER-PROJECT app-server we `thread/start
379
+ // { cwd }` (codex resolves config/hooks/AGENTS.md from that worktree cwd — exactly as claude loads CLAUDE.md
380
+ // per-worktree — so one project-scoped server behaves analogously to a per-worktree launch), and the result
381
+ // carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
382
+ // fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
383
+ // Never throws.
384
+ export function codexStartThread(sock: string, cwd?: string): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
385
+ return new Promise((resolve) => {
386
+ const conn: Socket = createConnection(sock)
387
+ const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
388
+ let upgraded = false, settled = false
389
+ const done = (r: { ok: true; threadId: string } | { ok: false; error: string }) => {
390
+ if (settled) return
391
+ settled = true
392
+ clearTimeout(timer)
393
+ try { conn.destroy() } catch { /* */ }
394
+ resolve(r)
395
+ }
396
+ const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not start a thread within 15000ms' }), 15000)
397
+ conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }))
398
+ conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/start was answered' }))
399
+ const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
400
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
401
+ const handle = (json: string) => {
402
+ let m: JsonRpc
403
+ try { m = JSON.parse(json) } catch { return }
404
+ if (m.error) return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
405
+ if (m.id === 1 && m.result) { send({ method: 'initialized', params: {} }); return send({ id: 2, method: 'thread/start', params: cwd ? { cwd } : {} }) }
406
+ if (m.id === 2 && m.result) {
407
+ const tid = (m.result as { thread?: { id?: string } })?.thread?.id
408
+ return tid ? done({ ok: true, threadId: tid }) : done({ ok: false, error: 'codex thread/start returned no thread id' })
409
+ }
410
+ }
411
+ conn.on('data', (chunk: Buffer) => {
412
+ fs.buf = Buffer.concat([fs.buf, chunk])
413
+ if (!upgraded) {
414
+ const i = fs.buf.indexOf('\r\n\r\n')
415
+ if (i < 0) return
416
+ const head = fs.buf.slice(0, i).toString('utf8')
417
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
418
+ upgraded = true
419
+ fs.buf = fs.buf.slice(i + 4)
420
+ send(wsInitialize)
421
+ }
422
+ if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: 'codex app-server sent a WebSocket close before thread/start was confirmed' })
423
+ })
424
+ })
425
+ }
426
+
427
+ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cwd?: string): Promise<DispatchResult> {
428
+ return new Promise((resolve) => {
429
+ const conn: Socket = createConnection(sock)
430
+ const hs = codexHandshakeMessages(threadId) // [initialize(1), initialized, thread/loaded/list(2), thread/read(3)]
431
+ let buf = Buffer.alloc(0), upgraded = false, settled = false
432
+ let fragOp = 0, fragBuf = Buffer.alloc(0)
433
+ let steering = false // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
434
+ const done = (r: DispatchResult) => {
435
+ if (settled) return
436
+ settled = true
437
+ clearTimeout(timer)
438
+ try { conn.destroy() } catch { /* */ }
439
+ resolve(r)
440
+ }
441
+ const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not confirm the turn within 5000ms' }), 5000)
442
+ conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }))
443
+ conn.on('close', () => done({ ok: false, error: 'codex app-server closed the connection before the turn was confirmed' }))
444
+ const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
445
+ conn.on('connect', () => {
446
+ const key = randomBytes(16).toString('base64')
447
+ conn.write(`GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`)
448
+ })
449
+ const handle = (json: string) => {
450
+ let m: JsonRpc
451
+ try { m = JSON.parse(json) } catch { return }
452
+ if (m.error) {
453
+ if (m.id === 4 && steering) // active turn ended in the read→steer window → just start a fresh turn
454
+ return send(codexInjectMessage(threadId, text, cwd, null, 5))
455
+ if (m.id === 3) // thread not readable yet (a freshly-started thread is "not materialized
456
+ return send(codexInjectMessage(threadId, text, cwd, null, 5)) // before its first user message") → no in-progress turn possible, so just turn/start
457
+ return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
458
+ }
459
+ if (m.id === 1 && m.result) return send(hs[2]) // initialize ack → ask which threads are loaded
460
+ if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
461
+ const loaded = (m.result as { data?: unknown })?.data
462
+ if (Array.isArray(loaded) && !loaded.includes(threadId))
463
+ return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — prompt NOT delivered` })
464
+ return send(hs[3]) // thread is live → read it to decide steer-vs-start
465
+ }
466
+ if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
467
+ const turnId = activeTurnIdFromThread(m.result)
468
+ steering = !!turnId
469
+ return send(codexInjectMessage(threadId, text, cwd, turnId)) // id 4: turn/steer the live turn, or turn/start
470
+ }
471
+ if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true }) // steer/start accepted → the model has the message
472
+ }
473
+ const drainFrames = () => {
474
+ for (;;) {
475
+ if (buf.length < 2) return
476
+ const b0 = buf[0], b1 = buf[1], op = b0 & 0x0f, fin = (b0 & 0x80) !== 0, masked = (b1 & 0x80) !== 0
477
+ let len = b1 & 0x7f, off = 2
478
+ if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4 }
479
+ else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10 }
480
+ const dataStart = off + (masked ? 4 : 0)
481
+ if (buf.length < dataStart + len) return
482
+ let payload = buf.slice(dataStart, dataStart + len)
483
+ if (masked) { const mk = buf.slice(off, off + 4); const u = Buffer.alloc(len); for (let i = 0; i < len; i++) u[i] = payload[i] ^ mk[i % 4]; payload = u }
484
+ buf = buf.slice(dataStart + len)
485
+ if (op === 0x8) return done({ ok: false, error: 'codex app-server sent a WebSocket close before turn/start was confirmed' })
486
+ if (op === 0x9) { conn.write(encodeWsFrame(0xa, payload)); continue } // ping → pong
487
+ if (op === 0xa) continue // pong
488
+ if (op === 0x0) fragBuf = Buffer.concat([fragBuf, payload]) // continuation
489
+ else { fragOp = op; fragBuf = payload }
490
+ if (fin) { if (fragOp === 0x1) handle(fragBuf.toString('utf8')); fragBuf = Buffer.alloc(0); fragOp = 0 }
491
+ }
492
+ }
493
+ conn.on('data', (chunk: Buffer) => {
494
+ buf = Buffer.concat([buf, chunk])
495
+ if (!upgraded) {
496
+ const i = buf.indexOf('\r\n\r\n')
497
+ if (i < 0) return
498
+ const head = buf.slice(0, i).toString('utf8')
499
+ if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
500
+ upgraded = true
501
+ buf = buf.slice(i + 4)
502
+ send(hs[0]); send(hs[1]) // initialize + the initialized notification; loaded/list → read → inject follow on the acks
503
+ }
504
+ drainFrames()
505
+ })
506
+ })
507
+ }
508
+
509
+ // fire a turn on an owned thread over the per-project socket — the same steer-vs-start delivery the live UI
510
+ // uses. The launcher calls this to materialize a freshly-started thread's rollout (the first turn = the launch
511
+ // prompt), and delivery reuses it for follow-ups. Exported so the CLI's `codex-launch` can fire the first turn.
512
+ export function codexTurn(sock: string, threadId: string, text: string, cwd?: string): Promise<DispatchResult> {
513
+ return sendCodexAppServerTurn(sock, threadId, text, cwd)
514
+ }
515
+
516
+ // codex's deliver: use the Codex app-server JSON-RPC channel that also powers rich clients, never TUI typing.
517
+ // The visible TUI is launched against the same project app-server Unix socket, so this injects into the same
518
+ // thread the pane is showing — steering an in-progress turn or starting one if idle. A missing captured thread
519
+ // id or socket is a loud failure; there is no tmux send-keys fallback because that reports "typed", not "accepted".
520
+ const pexec = promisify(execFile)
521
+ const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
522
+ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult> {
523
+ // the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
524
+ // the record picks out THIS session's thread.
525
+ const sock = codexAppServerSock(rec.runtimeDir)
526
+ if (!existsSync(sock)) return { ok: false, error: `no Codex app-server socket for this project — prompt NOT delivered` }
527
+ // use the backend-owned thread id stored at launch; fall back to reading the one loaded thread only if it's
528
+ // empty (a pre-existing session from before the id was stored).
529
+ let threadId = rec.harnessSessionId
530
+ if (!threadId) {
531
+ const r = await codexThreadId(sock)
532
+ if (!r.ok) return { ok: false, error: `${r.error} — prompt NOT delivered` }
533
+ threadId = r.threadId
534
+ }
535
+ return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath)
536
+ }
537
+
538
+ // idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
539
+ // comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
540
+ // (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
541
+ export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): void {
542
+ const [open, close] = comment
543
+ const START = `${open}spexcode:start${close}`
544
+ const END = `${open}spexcode:end${close}`
545
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
546
+ const block = `${START}\n${body}\n${END}`
547
+ let cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
548
+ const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`)
549
+ if (re.test(cur)) cur = cur.replace(re, block)
550
+ else cur = cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
551
+ writeFileSync(file, cur)
552
+ }
553
+
554
+ // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
555
+ // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
556
+ // the cli-needing handlers + the gate's `spex materialize`.
557
+ function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { json: string; cmd: (e: string) => string } {
558
+ const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
559
+ const hooks: Record<string, unknown> = {}
560
+ for (const e of events) hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }]
561
+ return { json: JSON.stringify({ hooks }, null, 2), cmd }
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------------------------------------
565
+ // Codex trust — the codex-rs trusted_hash, reverse-engineered + pinned. Lives in the Codex adapter (it is a
566
+ // codex-only fact); Claude has no analog.
567
+
568
+ // Codex trust keys + the hash use snake_case event labels (codex hook_event_key_label).
569
+ const SNAKE: Record<string, string> = {
570
+ SessionStart: 'session_start', UserPromptSubmit: 'user_prompt_submit', PreToolUse: 'pre_tool_use',
571
+ PostToolUse: 'post_tool_use', Stop: 'stop',
572
+ }
573
+
574
+ // @@@ codexHookHash - the trusted_hash codex computes (from codex-rs: command_hook_hash + version_for_toml):
575
+ // sha256 of the canonical (recursively key-sorted, compact) JSON of {event_name, hooks:[{type,command,timeout,
576
+ // async}]}; None fields omitted. Verified against live codex 0.142.3 samples.
577
+ export function codexHookHash(snakeEvent: string, command: string, timeout = 600, asyncFlag = false): string {
578
+ const canon = (v: unknown): unknown =>
579
+ v && typeof v === 'object' && !Array.isArray(v)
580
+ ? Object.fromEntries(Object.keys(v as object).sort().map((k) => [k, canon((v as Record<string, unknown>)[k])]))
581
+ : Array.isArray(v) ? v.map(canon) : v
582
+ const obj = { event_name: snakeEvent, hooks: [{ type: 'command', command, timeout, async: asyncFlag }] }
583
+ return 'sha256:' + createHash('sha256').update(JSON.stringify(canon(obj))).digest('hex')
584
+ }
585
+
586
+ // additively stamp directory + per-hook trust into the user's GLOBAL ~/.codex/config.toml so a user-self-
587
+ // launched codex skips the trust prompts. Scoped to THIS project path; replaces our own prior block (between
588
+ // sentinels) idempotently; never touches the user's other config. CODEX_HOME respected for testability.
589
+ function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): void {
590
+ const home = process.env.CODEX_HOME || join(homedir(), '.codex')
591
+ const file = join(home, 'config.toml')
592
+ const hooksJson = join(proj, '.codex', 'hooks.json')
593
+ const lines = [`[projects."${proj}"]`, 'trust_level = "trusted"']
594
+ for (const e of events) {
595
+ const snake = SNAKE[e]
596
+ lines.push(`[hooks.state."${hooksJson}:${snake}:0:0"]`, `trusted_hash = "${codexHookHash(snake, cmdFor(e))}"`)
597
+ }
598
+ const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
599
+ const esc = proj.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
600
+ let cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
601
+ const re = new RegExp(`# spexcode:trust:${esc} \\(managed[\\s\\S]*?# spexcode:trust:end:${esc}`)
602
+ if (re.test(cur)) cur = cur.replace(re, blk)
603
+ else cur = cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${blk}\n` : `${blk}\n`
604
+ if (!existsSync(home)) mkdirSync(home, { recursive: true })
605
+ writeFileSync(file, cur)
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------------------------------------
609
+ // the two implementations.
610
+
611
+ const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
612
+ const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
613
+
614
+ export const claudeHarness: Harness = {
615
+ id: 'claude',
616
+ events: CLAUDE_EVENTS,
617
+ ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
618
+ paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
619
+ launchCmd: () => process.env.SPEXCODE_CLAUDE_CMD || 'claude --dangerously-skip-permissions',
620
+ sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
621
+ sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
622
+ shimFile: (proj) => join(proj, '.claude', 'settings.json'),
623
+ contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
624
+ skillDir: (proj) => join(proj, '.claude', 'skills'),
625
+ shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
626
+ writeTrust: () => { /* Claude relies on folder-trust — nothing to write */ },
627
+ slashCommands: claudeSlashCommands,
628
+ liveness: (rec, tmuxAlive) => (tmuxAlive && existsSync(rvSock(rec.session)) ? 'online' : 'offline'),
629
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
630
+ resumeArg: (rec) => `--resume ${rec.session}`,
631
+ }
632
+
633
+ export const codexHarness: Harness = {
634
+ id: 'codex',
635
+ events: CODEX_EVENTS,
636
+ ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
637
+ paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
638
+ launchCmd: (id, runtimeDir) => codexLaunchCommand(id, undefined, undefined, runtimeDir ?? runtimeRoot()), // ONE app-server per PROJECT
639
+ sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
640
+ sessionEnvVar: 'CODEX_THREAD_ID',
641
+ // Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
642
+ // (codex-rs `root_checkout_hooks_folder_for_dir` rewrites the hooks-config folder to <repo_root>/<rel>/.codex
643
+ // for any linked worktree). Every worktree's thread (cwd = worktree root) therefore reads the SAME
644
+ // <mainCheckout>/.codex/hooks.json — so the codex hooks shim + its trust materialize at the MAIN checkout
645
+ // (one per project, mirroring the per-project runtime tier), while the AGENTS.md contract + skills stay
646
+ // per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
647
+ // cwd, so one shared shim serves every worktree.
648
+ shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
649
+ contractFiles: (proj) => [join(proj, 'AGENTS.md')],
650
+ skillDir: (proj) => join(proj, '.codex', 'skills'),
651
+ shim: (dispatch, spex) => buildShim('codex', CODEX_EVENTS, dispatch, spex),
652
+ writeTrust: (proj, cmdFor) => writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor),
653
+ slashCommands: codexSlashCommands,
654
+ liveness: (rec, tmuxAlive, runtimeDir) => (tmuxAlive && existsSync(codexAppServerSock(runtimeDir)) ? 'online' : 'offline'),
655
+ deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
656
+ // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
657
+ // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
658
+ // the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
659
+ resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
660
+ }
661
+
662
+ // every adapter — materialize iterates this to render each harness's artifacts in one pass.
663
+ export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
664
+
665
+ // the harness the dashboard/CLI launcher drives today (Claude). The single place a future codex launcher
666
+ // would flip; product code reads this rather than naming Claude.
667
+ export const defaultHarness: Harness = claudeHarness
668
+
669
+ // resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
670
+ export function harnessById(id: string): Harness {
671
+ const h = HARNESSES.find((x) => x.id === id)
672
+ if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
673
+ return h
674
+ }