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.
- package/bin/spex.mjs +15 -0
- package/dashboard-dist/assets/index-B60MILFg.js +139 -0
- package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
- package/dashboard-dist/index.html +16 -0
- package/package.json +35 -0
- package/src/board.ts +119 -0
- package/src/cli.ts +487 -0
- package/src/client.ts +102 -0
- package/src/gateway.ts +241 -0
- package/src/git.ts +492 -0
- package/src/guide.ts +134 -0
- package/src/harness.ts +674 -0
- package/src/hooks.ts +41 -0
- package/src/index.ts +233 -0
- package/src/init.ts +120 -0
- package/src/layout.ts +246 -0
- package/src/lint.ts +206 -0
- package/src/login-page.ts +79 -0
- package/src/materialize.ts +85 -0
- package/src/pty-bridge.ts +235 -0
- package/src/ranker.ts +129 -0
- package/src/resilience.ts +41 -0
- package/src/search.bench.mjs +47 -0
- package/src/search.ts +24 -0
- package/src/self.ts +256 -0
- package/src/sessions.ts +1469 -0
- package/src/slash-commands.ts +242 -0
- package/src/specs.ts +331 -0
- package/src/supervise.ts +158 -0
- package/src/uploads.ts +31 -0
- package/templates/hooks/pre-commit +57 -0
- package/templates/hooks/prepare-commit-msg +14 -0
- package/templates/spec/project/.config/core/idle/idle.sh +15 -0
- package/templates/spec/project/.config/core/idle/spec.md +13 -0
- package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
- package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
- package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
- package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
- package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
- package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
- package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.config/core/spec.md +13 -0
- package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
- package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
- package/templates/spec/project/.config/extract/spec.md +60 -0
- package/templates/spec/project/.config/forge-link/spec.md +9 -0
- package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
- package/templates/spec/project/.config/regroup/spec.md +25 -0
- package/templates/spec/project/.config/scenario/spec.md +32 -0
- package/templates/spec/project/.config/spec.md +15 -0
- package/templates/spec/project/.config/supervisor/spec.md +8 -0
- package/templates/spec/project/.config/tidy/spec.md +25 -0
- package/templates/spec/project/spec.md +19 -0
- 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
|
+
}
|