spexcode 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +49 -13
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/harness.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { homedir, tmpdir } from 'node:os'
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto'
|
|
5
5
|
import { createConnection, type Socket } from 'node:net'
|
|
6
|
-
import { execFile } from 'node:child_process'
|
|
6
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
|
|
@@ -24,6 +24,11 @@ import { tsxBin } from './tsx-bin.js'
|
|
|
24
24
|
|
|
25
25
|
export type HarnessId = 'claude' | 'codex'
|
|
26
26
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
|
|
27
|
+
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
28
|
+
// the pane's root pid (tmux `#{pane_pid}`) plus one whole-box pid→(ppid, comm) table (a single `ps` spawn).
|
|
29
|
+
// The adapter that cares (codex) walks the pane pid's descendants in that table; claude ignores it.
|
|
30
|
+
export type ProcTable = Map<number, { ppid: number; comm: string }>
|
|
31
|
+
export type PaneProbe = { panePid?: number; procs?: ProcTable }
|
|
27
32
|
|
|
28
33
|
export interface Harness {
|
|
29
34
|
readonly id: HarnessId
|
|
@@ -44,9 +49,19 @@ export interface Harness {
|
|
|
44
49
|
readonly paneTitleIsSelfSummary: boolean
|
|
45
50
|
|
|
46
51
|
// --- launch / sessionId ---
|
|
47
|
-
// the base agent command
|
|
48
|
-
//
|
|
49
|
-
|
|
52
|
+
// the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
|
|
53
|
+
// visible TUI with `--remote` pointed at it. `cmd`, when given, is the SESSION's persisted launcher command
|
|
54
|
+
// ([[launcher-select]]) and OVERRIDES the ambient env→config→default resolution — so a session created under a
|
|
55
|
+
// named launcher keeps that exact command (and auth) on resume, never silently reverting to the global
|
|
56
|
+
// default. Omitted → the unnamed default resolution (env-overridable, for tests + old records).
|
|
57
|
+
launchCmd(id: string, runtimeDir?: string, cmd?: string): string
|
|
58
|
+
// the RESOLVED base launcher command alone — the wrapper/binary that carries the agent's config-dir env
|
|
59
|
+
// (claude `CLAUDE_CONFIG_DIR`, codex `CODEX_HOME`), WITHOUT the per-launch script built around it. `cmd`,
|
|
60
|
+
// when given, wins; else the ambient env→config→default resolution. The launch owner PINS this on the record
|
|
61
|
+
// at creation so a resume replays the EXACT launcher that created the conversation — never re-resolving
|
|
62
|
+
// against a since-changed default, which would point `--resume` at the wrong config dir and lose the
|
|
63
|
+
// transcript ([[launcher-select]], the resume-launcher-pin). launchCmd builds its invocation ON TOP of this.
|
|
64
|
+
baseCmd(cmd?: string): string
|
|
50
65
|
// the flag that pins the session id at launch. Claude lets the caller choose (`--session-id <id>`); Codex
|
|
51
66
|
// assigns its own, so there is nothing to pass (the id is captured/resumed afterwards).
|
|
52
67
|
sessionIdArg(id: string): string
|
|
@@ -56,16 +71,37 @@ export interface Harness {
|
|
|
56
71
|
// --- materialize: shim + contract + trust ([[harness-delivery]]) ---
|
|
57
72
|
// the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
|
|
58
73
|
shimFile(proj: string): string
|
|
74
|
+
// a LINKED WORKTREE's extra shim copy — the worktree-side `.codex` hook file that ANCHORS codex's project
|
|
75
|
+
// config layer, or null when the harness needs none. codex-rs only builds a project config layer (and thus
|
|
76
|
+
// only DISCOVERS a worktree thread's hooks) for a dir in [cwd..project_root] that contains a `.codex/`
|
|
77
|
+
// directory; it then REWRITES that layer's hooks-config folder to the ROOT checkout (root_checkout_hooks_-
|
|
78
|
+
// folder_for_dir), so the shim CONTENT is still read from `shimFile` at the main checkout. But with the codex
|
|
79
|
+
// shim living ONLY at the main checkout, a linked worktree has NO `.codex/` at all → codex anchors no layer →
|
|
80
|
+
// the rewritten root hooks are never visited → ZERO hooks fire (bypass_hook_trust can't help: it only rescues
|
|
81
|
+
// an untrusted HANDLER inside an already-discovered layer, it never creates one). So codex ALSO writes its
|
|
82
|
+
// shim into the worktree's own `.codex/hooks.json` purely to anchor the layer (the rewrite ignores its
|
|
83
|
+
// content, reading the root's — and a codex that DIDN'T rewrite would read this identical shim, so it is
|
|
84
|
+
// correct either way). Claude: null — its shim already lives IN the worktree (`.claude/settings.json`) and
|
|
85
|
+
// self-anchors; it has no root-checkout rewrite. Non-worktree (proj == main checkout): null — `shimFile`
|
|
86
|
+
// already wrote `.codex/hooks.json` there.
|
|
87
|
+
worktreeHookAnchor(proj: string): string | null
|
|
59
88
|
// the contract file(s) the `surface: system` block is folded into. Claude: ./CLAUDE.md; Codex: ONLY ./AGENTS.md.
|
|
60
89
|
contractFiles(proj: string): string[]
|
|
61
90
|
// the dir this harness auto-discovers skills from, or null if it has no skill primitive — the ONLY place skill-surface divergence lives.
|
|
62
91
|
skillDir(proj: string): string | null
|
|
92
|
+
// the dir this harness auto-discovers sub-agent definitions from, or null if it has no agent primitive — the
|
|
93
|
+
// ONLY place agent-surface divergence lives (the skillDir analog). Claude reads .claude/agents/<name>.md;
|
|
94
|
+
// Codex has no file-discovered agent-definition primitive, so it returns null and materialize skips it.
|
|
95
|
+
agentDir(proj: string): string | null
|
|
63
96
|
// the shim payload: the settings/hooks JSON binding every event → the dispatcher (harness id baked in), and
|
|
64
97
|
// the per-event command string (shared with the trust writer so they hash identically).
|
|
65
98
|
shim(dispatch: string, spex: string): { json: string; cmd: (e: string) => string }
|
|
66
|
-
// make a
|
|
67
|
-
//
|
|
68
|
-
// (
|
|
99
|
+
// make a dispatched/self-launched agent run the hooks with zero prompts. Codex writes PROJECT trust — and, on
|
|
100
|
+
// a binary without `--dangerously-bypass-hook-trust`, per-hook trusted_hash blocks — into the GLOBAL
|
|
101
|
+
// ~/.codex/config.toml (codex's security model: trust is global-only). PROJECT trust is UNCONDITIONAL: it
|
|
102
|
+
// ENABLES the project config layer so codex discovers our hooks at all, a tier bypass_hook_trust does NOT
|
|
103
|
+
// cover. Claude is a no-op (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim
|
|
104
|
+
// emitted.
|
|
69
105
|
writeTrust(proj: string, cmdFor: (e: string) => string): void
|
|
70
106
|
|
|
71
107
|
// --- the `/` menu ---
|
|
@@ -73,21 +109,38 @@ export interface Harness {
|
|
|
73
109
|
slashCommands(): SlashCommand[]
|
|
74
110
|
|
|
75
111
|
// --- runtime: liveness + prompt delivery ([[harness-delivery]]) ---
|
|
76
|
-
// is this session's agent process up? The caller passes
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
112
|
+
// is this session's agent process up? The caller passes the runtime facts it already computed in ONE
|
|
113
|
+
// snapshot (see sessions.ts liveSnapshot): the window's presence, a PaneProbe — the pane's root pid plus one
|
|
114
|
+
// whole-box process table — AND `socketLive`, whether a CONNECT to this session's rendezvous socket found a
|
|
115
|
+
// live listener (the caller probes all windowed sessions once per snapshot). The adapter adds only its own
|
|
116
|
+
// channel check. claude: online iff the window is up AND its reclaude rendezvous socket has a live LISTENER
|
|
117
|
+
// (`socketLive` — a connect that a live claude accepts and a stale socket FILE refuses; claude IGNORES the
|
|
118
|
+
// pane probe). codex: online iff the window is up AND a codex-ish process (`codex` by any
|
|
119
|
+
// name, or the `node` its CLI runs under) is live in the pane pid's DESCENDANT tree — NOT the pane's
|
|
120
|
+
// foreground command name (that is `bash`, the launch wrapper, even while the TUI renders — field-confirmed),
|
|
121
|
+
// and NOT the SHARED per-project app-server socket (it stays bound after a failed `--remote resume` dropped
|
|
122
|
+
// the pane back to the shell). A missing probe (tmux/ps couldn't report) is not-live. The 'starting' boot
|
|
123
|
+
// grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
|
|
124
|
+
liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
|
|
84
125
|
// deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
|
|
85
|
-
// control socket
|
|
86
|
-
//
|
|
126
|
+
// control socket — OPTIMISTIC-after-liveness (loud failure when the socket is missing/dead or the write can't
|
|
127
|
+
// flush; once the reply line flushes to a live socket it returns ok, without waiting for an application ack —
|
|
128
|
+
// see replyViaSocket). codex: JSON-RPC on the same app-server WebSocket the
|
|
87
129
|
// visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
|
|
88
130
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
89
131
|
// Returns ok=false with a reason that propagates to the API.
|
|
90
132
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
133
|
+
// --- materialize: clean (the inverse of write — [[harness-select]] prunes a deselected harness) ---
|
|
134
|
+
// clean is the EXACT inverse of materialize's per-harness write: SURGICALLY remove ONLY SpexCode's own
|
|
135
|
+
// artifacts — the managed contract block (sentinels), the generated shim file, the trust block, and the
|
|
136
|
+
// skill/agent files named in `arts` — never the user's surrounding prose, their other settings, or any .spec
|
|
137
|
+
// data. materialize calls it for every UNSELECTED harness, so dropping a harness from spexcode.json's
|
|
138
|
+
// `harnesses` prunes that harness's products on the next re-materialize.
|
|
139
|
+
clean(proj: string, arts: HarnessArtifacts): void
|
|
140
|
+
// the inverse of writeTrust: strip THIS project's spexcode trust block from the harness's global config.
|
|
141
|
+
// Codex removes its `~/.codex/config.toml` block; Claude is a no-op (it wrote none).
|
|
142
|
+
removeTrust(proj: string): void
|
|
143
|
+
|
|
91
144
|
// the relaunch tail reopen() hands launch() to bring the SAME work back up. claude resumes the same
|
|
92
145
|
// conversation (`--resume <id>`, the id we pinned at launch). codex's own thread id is un-pinnable on the
|
|
93
146
|
// launch flag, so the BACKEND owns it: it `thread/start`s the thread and stores the id at launch, so reopen
|
|
@@ -97,12 +150,18 @@ export interface Harness {
|
|
|
97
150
|
resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
|
|
98
151
|
}
|
|
99
152
|
|
|
100
|
-
// a prompt-dispatch outcome. ok=true
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
153
|
+
// a prompt-dispatch outcome. ok=true means delivery is confirmed at the layer that harness proves it: claude at
|
|
154
|
+
// the TRANSPORT/liveness layer (the reply line flushed to a live rendezvous socket — see replyViaSocket for why
|
|
155
|
+
// it is OPTIMISTIC-after-liveness, not a round-trip ack); codex at the application layer (the app-server accepted
|
|
156
|
+
// `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route (non-2xx)
|
|
157
|
+
// and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts re-exports it for
|
|
158
|
+
// its existing importers.
|
|
104
159
|
export type DispatchResult = { ok: boolean; error?: string }
|
|
105
160
|
export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
|
|
161
|
+
// the on-demand surface artifacts a materialize render wrote, by node NAME — so clean() knows EXACTLY which
|
|
162
|
+
// skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
|
|
163
|
+
// also populate). materialize passes the live skill/agent node names; clean reconstructs the same paths.
|
|
164
|
+
export type HarnessArtifacts = { skills: readonly string[]; agents: readonly string[] }
|
|
106
165
|
|
|
107
166
|
// @@@ rendezvous control socket - claude's DETERMINISTIC, ONLY input path for PROMPTS to sessions WE launch.
|
|
108
167
|
// sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
|
|
@@ -110,12 +169,58 @@ export type HarnessDeliveryRecord = { session: string; worktreePath?: string; ha
|
|
|
110
169
|
// `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
|
|
111
170
|
// and Enters can't be corrupted the way `tmux send-keys` was. The path is uniquely derived from the session id,
|
|
112
171
|
// so we only ever address OUR OWN sockets (HARD ethics rule: never touch a session outside this product). It
|
|
113
|
-
// lives in tmpdir tied to the claude process, so no extra lifecycle. liveness
|
|
114
|
-
//
|
|
115
|
-
// var from it and best-effort sweeps it on close — but the liveness/delivery USE is the adapter's, below.
|
|
172
|
+
// lives in tmpdir tied to the claude process, so no extra lifecycle. liveness CONNECTS to it (a live LISTENER,
|
|
173
|
+
// not merely the file — see rendezvousListening); deliver writes to it. Exported because sessions.ts builds the
|
|
174
|
+
// launch env var from it and best-effort sweeps it on close — but the liveness/delivery USE is the adapter's, below.
|
|
116
175
|
export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
|
|
117
|
-
|
|
118
|
-
|
|
176
|
+
|
|
177
|
+
// @@@ rendezvousListening - the LISTENER check that IS claude's liveness truth ([[state]], [[harness-adapter]]).
|
|
178
|
+
// A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
|
|
179
|
+
// an unclean exit), so the old `existsSync(rvSock)` read a DEAD pane as `online` for as long as the stale file
|
|
180
|
+
// lingered — the incident's "dead pane stuck `working` for 30+ min". The honest signal is a live LISTENER:
|
|
181
|
+
// connect() to the socket. A real claude is accepting → connects; a stale file → ECONNREFUSED (instant); an
|
|
182
|
+
// absent file → ENOENT (instant). So the common cases cost no waiting; the short timeout only bounds the
|
|
183
|
+
// pathological file-present-but-listener-wedged case (then treated as not-live). Never throws.
|
|
184
|
+
export function rendezvousListening(id: string, timeoutMs = 800): Promise<boolean> {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
let settled = false
|
|
187
|
+
let c: ReturnType<typeof createConnection> | undefined
|
|
188
|
+
const done = (v: boolean) => {
|
|
189
|
+
if (settled) return
|
|
190
|
+
settled = true
|
|
191
|
+
clearTimeout(timer)
|
|
192
|
+
try { c?.destroy() } catch { /* */ }
|
|
193
|
+
resolve(v)
|
|
194
|
+
}
|
|
195
|
+
const timer = setTimeout(() => done(false), timeoutMs)
|
|
196
|
+
try { c = createConnection({ path: rvSock(id) }) } catch { return done(false) }
|
|
197
|
+
c.on('connect', () => done(true))
|
|
198
|
+
c.on('error', () => done(false)) // ECONNREFUSED (stale file) / ENOENT (gone) — both mean no live agent
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
// The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
|
|
202
|
+
// dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
|
|
203
|
+
// dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
|
|
204
|
+
// project (~111 chars) → `path must be shorter than SUN_LEN` + connect EINVAL, and the app-server never bound
|
|
205
|
+
// (Linux's 108 limit + shorter `/root` paths happened to fit; macOS did not). So the socket is
|
|
206
|
+
// `<socketBase>/spexcode-cx-<hash>.sock`, where `<hash>` is a short STABLE digest of the PROJECT identity — the
|
|
207
|
+
// `dir` (runtimeDir) the callers pass — so launch, liveness, and delivery all compute the IDENTICAL sock for a
|
|
208
|
+
// given project (the ONE-app-server-per-project invariant). This is UNCONDITIONAL on every platform (a short
|
|
209
|
+
// hashed path is strictly better everywhere — no darwin branch; platform differences stay at this path seam).
|
|
210
|
+
// `<socketBase>` = the `SPEXCODE_CODEX_SOCKET_DIR` override, else an OWNED per-uid subdir of the platform
|
|
211
|
+
// tmpdir (`spexcode-cx-<uid>`, created 0700) — NEVER bare tmpdir: codex (0.137+ field-confirmed) refuses to
|
|
212
|
+
// bind a unix socket directly in the shared sticky `/tmp` on a host with `fs.protected_regular=2` (EPERM), so
|
|
213
|
+
// the bare-tmpdir default failed every codex launch on a stock hardened Ubuntu out of the box (github#30),
|
|
214
|
+
// while the SAME codex binds fine in any owned subdirectory. Per-uid (not one shared `spexcode-cx`) so a
|
|
215
|
+
// second user on the box never lands in the first user's 0700 dir. The derivation GUARANTEES the dir exists
|
|
216
|
+
// (idempotent mkdir) so every consumer — launch bake, liveness connect, delivery, tests — shares one creation
|
|
217
|
+
// point. The `.pid`/`.log`/`.lock` files carry no sun_path limit and stay in `runtimeRoot`.
|
|
218
|
+
export const codexAppServerSock = (dir = runtimeRoot()) => {
|
|
219
|
+
const base = process.env.SPEXCODE_CODEX_SOCKET_DIR || join(tmpdir(), `spexcode-cx-${process.getuid?.() ?? 0}`)
|
|
220
|
+
mkdirSync(base, { recursive: true, mode: 0o700 })
|
|
221
|
+
return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
|
|
222
|
+
}
|
|
223
|
+
export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
|
|
119
224
|
|
|
120
225
|
function shQuote(s: string): string {
|
|
121
226
|
return `'${s.replace(/'/g, `'\\''`)}'`
|
|
@@ -127,31 +232,30 @@ function shQuote(s: string): string {
|
|
|
127
232
|
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
128
233
|
const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
|
|
129
234
|
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
235
|
+
// @@@ replyViaSocket - OPTIMISTIC-after-liveness delivery: connect to the LIVE rendezvous socket and WRITE the
|
|
236
|
+
// `{type:reply}\n` line; once that line FLUSHES to the socket with no immediate transport error, the reply is on
|
|
237
|
+
// the wire and claude submits it the moment it yields the event loop — so we return ok:true right there. We do
|
|
238
|
+
// NOT wait for an application-level acceptance ack. The old code wrote a `{type:repaint}` probe after the reply
|
|
239
|
+
// and waited up to 2500ms for a `{type:repaint-done}` to CONFIRM the reply was processed; the ordering barrier is
|
|
240
|
+
// sound one-way (repaint-done ⟹ delivered) but its ABSENCE is NOT proof of non-delivery — a mid-turn/BUSY claude
|
|
241
|
+
// can't answer the probe within the wall, so a message that actually landed reported a COMMON, misleading false
|
|
242
|
+
// failure. The delivery confirmation therefore lives at the TRANSPORT/liveness layer, not the application layer:
|
|
243
|
+
// deliverViaRendezvous already gates on the socket's existence (liveness), and here the FREE, INSTANT transport
|
|
244
|
+
// signals — a connect that throws, a socket 'error' (ECONNREFUSED / EPIPE), or a 'close' BEFORE the write flushes —
|
|
245
|
+
// are still reported as real failures (they cost no waiting). What we knowingly give up is detection of the narrow
|
|
246
|
+
// alive-but-wedged / reply-rejected / shutting-down cases: a rare silent drop, recoverable because the supervisor
|
|
247
|
+
// sees the worker never transition. That tradeoff is the deliberate contract now — see [[harness-adapter]].
|
|
248
|
+
// Never throws.
|
|
140
249
|
function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
|
|
141
250
|
return new Promise((resolve) => {
|
|
142
|
-
let settled = false
|
|
251
|
+
let settled = false
|
|
143
252
|
let c: ReturnType<typeof createConnection>
|
|
144
253
|
const done = (r: DispatchResult) => {
|
|
145
254
|
if (settled) return
|
|
146
255
|
settled = true
|
|
147
|
-
clearTimeout(timer)
|
|
148
256
|
try { c?.destroy() } catch { /* */ }
|
|
149
257
|
resolve(r)
|
|
150
258
|
}
|
|
151
|
-
const timer = setTimeout(
|
|
152
|
-
() => done({ ok: false, error: `rendezvous socket gave no acceptance confirmation within ${ACCEPT_TIMEOUT_MS}ms` }),
|
|
153
|
-
ACCEPT_TIMEOUT_MS,
|
|
154
|
-
)
|
|
155
259
|
try {
|
|
156
260
|
c = createConnection({ path: sock })
|
|
157
261
|
} catch (e) {
|
|
@@ -159,26 +263,15 @@ function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
|
|
|
159
263
|
return
|
|
160
264
|
}
|
|
161
265
|
c.on('error', (e: NodeJS.ErrnoException) => done({ ok: false, error: `rendezvous socket connect failed: ${e?.code || String(e)}` }))
|
|
162
|
-
c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let i: number
|
|
167
|
-
while ((i = buf.indexOf('\n')) >= 0) {
|
|
168
|
-
const line = buf.slice(0, i); buf = buf.slice(i + 1)
|
|
169
|
-
if (!line) continue
|
|
170
|
-
let type: string | undefined
|
|
171
|
-
try { type = JSON.parse(line)?.type } catch { continue } // ignore any non-JSON noise on the wire
|
|
172
|
-
if (type === 'reply-rejected') return done({ ok: false, error: 'agent REJECTED the prompt (rendezvous reply-rejected — auth-gated daemon?)' })
|
|
173
|
-
if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt not accepted' })
|
|
174
|
-
if (type === 'repaint-done') return done({ ok: true }) // reply was enqueued in-order before this
|
|
175
|
-
// heartbeat / state / other frames → keep waiting for the decisive repaint-done or a rejection.
|
|
176
|
-
}
|
|
177
|
-
})
|
|
266
|
+
c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was written' }))
|
|
267
|
+
// write the reply line; the write callback fires once it flushes to the live socket → optimistic ok. An
|
|
268
|
+
// immediate transport 'error'/'close' beats the callback and reports the real failure (done is idempotent).
|
|
269
|
+
c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n', () => done({ ok: true })))
|
|
178
270
|
})
|
|
179
271
|
}
|
|
180
|
-
// claude's deliver: fail loud BEFORE attempting the socket if it isn't there (a
|
|
181
|
-
// connect error
|
|
272
|
+
// claude's deliver: the pre-write LIVENESS gate — fail loud BEFORE attempting the socket if it isn't there (a
|
|
273
|
+
// clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent alive).
|
|
274
|
+
// Then inject the reply optimistically via replyViaSocket (no round-trip ack).
|
|
182
275
|
function deliverViaRendezvous(id: string, text: string): Promise<DispatchResult> {
|
|
183
276
|
const sock = rvSock(id)
|
|
184
277
|
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` })
|
|
@@ -233,8 +326,50 @@ export function activeTurnIdFromThread(readResult: unknown): string | null {
|
|
|
233
326
|
return active?.id ?? null
|
|
234
327
|
}
|
|
235
328
|
|
|
236
|
-
|
|
237
|
-
|
|
329
|
+
// The app-server and the visible `--remote … resume` TUI share ONE socket, so they MUST be the SAME codex
|
|
330
|
+
// install — a version split across that socket breaks the thread/start→resume handoff (an app-server on one
|
|
331
|
+
// version creates a thread a differently-versioned resume can't find; an old-enough app-server can't serve
|
|
332
|
+
// `--remote unix://` at all). So `serverCmd` is DERIVED from the in-effect `codexCmd`'s binary (its first shell
|
|
333
|
+
// token, dropping args like `--yolo`) whenever it isn't explicitly forced: `<bin> app-server` then runs the
|
|
334
|
+
// SAME install as `<bin> --remote … resume`. Bare `codex` is NOT the default anymore — on a multi-install host
|
|
335
|
+
// (e.g. homebrew codex shadowing an nvm codex) a bare `codex` resolves via the login-shell PATH to a DIFFERENT
|
|
336
|
+
// binary than the launcher's, which is exactly the version-skew bug. `SPEXCODE_CODEX_SERVER_CMD` stays the
|
|
337
|
+
// explicit escape hatch (highest precedence). Caveat: if `codexCmd`'s first token is a WRAPPER script rather
|
|
338
|
+
// than codex itself, the derived `<wrapper> app-server` only works if the wrapper forwards to codex — the
|
|
339
|
+
// common direct-binary case (`codex …`, `/abs/codex --yolo`) is what this fixes.
|
|
340
|
+
export function codexBinary(codexCmd: string): string {
|
|
341
|
+
return codexCmd.trim().split(/\s+/)[0] || 'codex'
|
|
342
|
+
}
|
|
343
|
+
// codex >=0.142 adds `--dangerously-bypass-hook-trust` — run our OWN (vetted) dispatch hooks without a persisted
|
|
344
|
+
// trusted_hash. We PREFER it over reverse-engineering codexHookHash: that hash is pinned to one codex version's
|
|
345
|
+
// format and silently breaks on a bump (codex then skips ALL our hooks -> no Stop gate, no mark-active, sessions
|
|
346
|
+
// die undeclared). The flag is version-robust. But an OLDER codex HARD-ERRORS on the unknown flag (the whole
|
|
347
|
+
// app-server fails to boot), so we CAPABILITY-PROBE the binary once (`--help` grep) and only pass it when
|
|
348
|
+
// present; otherwise the writeCodexTrust hash path still stands in. Memoized — a per-binary constant.
|
|
349
|
+
const bypassProbe = new Map<string, boolean>()
|
|
350
|
+
export function codexSupportsBypassHookTrust(binary: string): boolean {
|
|
351
|
+
// explicit escape hatch (also what makes this deterministic in tests): force the capability on/off regardless
|
|
352
|
+
// of the binary — e.g. if the `--help` probe is unreliable on a wrapper, or to pin behaviour.
|
|
353
|
+
const env = process.env.SPEXCODE_CODEX_BYPASS_HOOK_TRUST
|
|
354
|
+
if (env !== undefined) return env === '1' || env === 'true'
|
|
355
|
+
const hit = bypassProbe.get(binary)
|
|
356
|
+
if (hit !== undefined) return hit
|
|
357
|
+
let ok = false
|
|
358
|
+
try { ok = execFileSync(binary, ['--help'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).includes('--dangerously-bypass-hook-trust') } catch { ok = false }
|
|
359
|
+
bypassProbe.set(binary, ok)
|
|
360
|
+
return ok
|
|
361
|
+
}
|
|
362
|
+
export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_CODEX_CMD || 'codex --yolo', serverCmd?: string, dir = runtimeRoot()): string {
|
|
363
|
+
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
364
|
+
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
365
|
+
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
366
|
+
// thread — it was INERT there, the bug). Two thread paths carry it: (1) the BACKEND-owned `thread/start` sends
|
|
367
|
+
// `config.bypass_hook_trust` from codex-launch ([[harness-adapter]]); (2) the visible `--remote … resume` TUI,
|
|
368
|
+
// where codex's OWN client forwards this flag into its thread/start+thread/resume config — so a reopen in a
|
|
369
|
+
// fresh app-server (where codex-launch never runs) still trusts our hooks. Hence the flag lives on the resume
|
|
370
|
+
// TUI, never on the app-server invocation. Guarded against a double-flag when an env override already carries it.
|
|
371
|
+
const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
|
|
372
|
+
const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
|
|
238
373
|
const pid = codexAppServerPid(dir)
|
|
239
374
|
const log = join(dir, 'codex-app-server.log')
|
|
240
375
|
const lock = join(dir, 'codex-app-server.lock')
|
|
@@ -244,27 +379,61 @@ export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_
|
|
|
244
379
|
`pid=${shQuote(pid)}`,
|
|
245
380
|
`log=${shQuote(log)}`,
|
|
246
381
|
`lock=${shQuote(lock)}`,
|
|
382
|
+
// codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
|
|
383
|
+
// WE already hold the launcher's real cmd here (it drives the app-server + resume TUI + tuiBypass above), so
|
|
384
|
+
// pin it into the environment the codex-launch child inherits. Without this the child falls back to a bare
|
|
385
|
+
// `codex`, which on a multi-install box (e.g. an old Homebrew codex on PATH beside the launcher's newer one)
|
|
386
|
+
// probes the WRONG binary — deciding "no --dangerously-bypass-hook-trust support" and silently dropping the
|
|
387
|
+
// thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
|
|
388
|
+
`export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
|
|
247
389
|
'mkdir -p "$dir"',
|
|
248
|
-
'(',
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
'
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// (
|
|
256
|
-
// it can
|
|
257
|
-
|
|
258
|
-
'
|
|
259
|
-
'
|
|
260
|
-
'
|
|
261
|
-
'
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
390
|
+
'mkdir -p -m 700 "$(dirname "$sock")"', // the socket base (owned tmp subdir or the SPEXCODE_CODEX_SOCKET_DIR override) — re-created here because a tmp cleaner may have wiped it since the bake; NEVER bare /tmp (codex EPERMs binding there on hardened hosts, github#30)
|
|
391
|
+
// self-heal: the pre-fix flock design left an orphaned `codex-app-server.lock` FILE; the mkdir mutex now
|
|
392
|
+
// uses `"$lock.d"`, so drop that dead residue on already-run deployments (harmless if absent).
|
|
393
|
+
'rm -f "$lock"',
|
|
394
|
+
// POSIX-portable mutex: mkdir is atomic on every POSIX fs, so it serializes the check-and-start with NO
|
|
395
|
+
// dependency on util-linux `flock` (absent on macOS — where the old flock path failed the whole app-server
|
|
396
|
+
// bootstrap, leaving the pane at the shell). Spin on `mkdir "$lock.d"` with a bounded wait; after ~10s
|
|
397
|
+
// (200 * 0.05s, safely above the ~5s socket-wait a legit holder needs) treat the dir as orphaned by a dead
|
|
398
|
+
// launcher and clear it, so a stale lock can never deadlock a launch. Held ONLY across the check-and-start,
|
|
399
|
+
// released immediately after. Unlike flock (held until every fd on its open file description closes) a mkdir
|
|
400
|
+
// lock has no inherited-fd hazard, so the long-lived daemon can't pin it — no fd-9 gymnastics needed.
|
|
401
|
+
'lockd="$lock.d"',
|
|
402
|
+
'_lk=0',
|
|
403
|
+
'until mkdir "$lockd" 2>/dev/null; do',
|
|
404
|
+
' _lk=$((_lk+1)); [ "$_lk" -ge 200 ] && { rm -rf "$lockd" 2>/dev/null; _lk=0; }',
|
|
405
|
+
' sleep 0.05',
|
|
406
|
+
'done',
|
|
407
|
+
'if [ -S "$sock" ] && [ -s "$pid" ] && ! kill -0 "$(cat "$pid")" 2>/dev/null; then rm -f "$sock"; fi',
|
|
408
|
+
'if [ ! -S "$sock" ]; then',
|
|
409
|
+
// The app-server is a per-PROJECT daemon SHARED across every worktree's threads, so it must run in a STABLE
|
|
410
|
+
// cwd — the runtime dir "$dir", NOT the launch.sh's transient worktree. A daemon started inside a worktree
|
|
411
|
+
// keeps that worktree as its process cwd for its whole life; when the session closes and the worktree is
|
|
412
|
+
// removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
|
|
413
|
+
// `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
|
|
414
|
+
// until the daemon is killed. Running it from "$dir" (which never gets deleted) makes it deletion-proof.
|
|
415
|
+
// exec so $! is the daemon itself; </dev/null detaches its stdin from the pane so it can't fight the TUI.
|
|
416
|
+
` ( cd "$dir" && exec ${server} app-server --listen unix://"$sock" >"$log" 2>&1 </dev/null ) &`,
|
|
417
|
+
' echo $! > "$pid"',
|
|
418
|
+
' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
|
|
419
|
+
'fi',
|
|
420
|
+
'rmdir "$lockd" 2>/dev/null',
|
|
421
|
+
// TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
|
|
422
|
+
// codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
|
|
423
|
+
// thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
|
|
424
|
+
// does thread/start { cwd = this worktree } on the shared per-project app-server, stores the new id on the
|
|
425
|
+
// governed record (SPEXCODE_SESSION_ID), and fires the tail as the FIRST turn, materializing the rollout.
|
|
426
|
+
// Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
|
|
427
|
+
// rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
|
|
428
|
+
// literal "--resume" marker — the discriminator is unambiguous. codex-launch only prints an id once its
|
|
429
|
+
// rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
|
|
430
|
+
`if [ "$1" = "--resume" ]; then`,
|
|
431
|
+
` tid=$2`,
|
|
432
|
+
`else`,
|
|
433
|
+
` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@") || exit 1`,
|
|
434
|
+
`fi`,
|
|
435
|
+
`[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
|
|
436
|
+
`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`,
|
|
268
437
|
].join('\n')
|
|
269
438
|
return `bash -lc ${shQuote(script)} spexcode-codex`
|
|
270
439
|
}
|
|
@@ -374,7 +543,7 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
|
|
|
374
543
|
// carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
|
|
375
544
|
// fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
|
|
376
545
|
// Never throws.
|
|
377
|
-
export function codexStartThread(sock: string, cwd?: string): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
|
|
546
|
+
export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
|
|
378
547
|
return new Promise((resolve) => {
|
|
379
548
|
const conn: Socket = createConnection(sock)
|
|
380
549
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
@@ -395,7 +564,18 @@ export function codexStartThread(sock: string, cwd?: string): Promise<{ ok: true
|
|
|
395
564
|
let m: JsonRpc
|
|
396
565
|
try { m = JSON.parse(json) } catch { return }
|
|
397
566
|
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)}` })
|
|
398
|
-
if (m.id === 1 && m.result) {
|
|
567
|
+
if (m.id === 1 && m.result) {
|
|
568
|
+
send({ method: 'initialized', params: {} })
|
|
569
|
+
// thread/start's `config` is the per-request override map the app-server reads (config_manager reads
|
|
570
|
+
// `request_overrides["bypass_hook_trust"]`) — the ONLY channel that reaches the thread config; the
|
|
571
|
+
// `--dangerously-bypass-hook-trust` flag on the `codex app-server` invocation is INERT (the app-server
|
|
572
|
+
// never reads it for a thread), so a BACKEND-owned thread must carry the bypass here, exactly as codex's
|
|
573
|
+
// own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
|
|
574
|
+
// stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
|
|
575
|
+
// the flag uses writeCodexTrust's hash and never sees this key).
|
|
576
|
+
const params = { ...(cwd ? { cwd } : {}), ...(bypassHookTrust ? { config: { bypass_hook_trust: true } } : {}) }
|
|
577
|
+
return send({ id: 2, method: 'thread/start', params })
|
|
578
|
+
}
|
|
399
579
|
if (m.id === 2 && m.result) {
|
|
400
580
|
const tid = (m.result as { thread?: { id?: string } })?.thread?.id
|
|
401
581
|
return tid ? done({ ok: true, threadId: tid }) : done({ ok: false, error: 'codex thread/start returned no thread id' })
|
|
@@ -506,6 +686,35 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
|
|
|
506
686
|
return sendCodexAppServerTurn(sock, threadId, text, cwd)
|
|
507
687
|
}
|
|
508
688
|
|
|
689
|
+
// @@@ codex rollout on disk - the visible TUI resumes a thread via `codex --remote resume <tid>`, which reads
|
|
690
|
+
// the thread's ROLLOUT FILE (`<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<ts>-<tid>.jsonl`) — so a thread the
|
|
691
|
+
// TUI can render is exactly one whose rollout exists on disk. VERIFIED live (real codex 0.142.5): `thread/start`
|
|
692
|
+
// ALONE writes NO rollout — only the first fired turn materializes it; and a FRESHLY-spawned app-server accepts
|
|
693
|
+
// thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
|
|
694
|
+
// rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
|
|
695
|
+
// found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
|
|
696
|
+
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
|
|
697
|
+
// does a rollout file for this thread id exist yet? Rollouts are grouped by date, so scan only the newest few
|
|
698
|
+
// day-dirs (lexical order = chronological on zero-padded YYYY/MM/DD) — clock-agnostic and cheap on a big history.
|
|
699
|
+
export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
|
|
700
|
+
const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
|
|
701
|
+
const days: string[] = []
|
|
702
|
+
for (const y of kids(root)) { for (const m of kids(join(root, y))) { for (const d of kids(join(root, y, m))) { days.push(join(root, y, m, d)); if (days.length >= 3) break } if (days.length >= 3) break } if (days.length >= 3) break }
|
|
703
|
+
return days.some((d) => kids(d).some((f) => f.includes(threadId)))
|
|
704
|
+
}
|
|
705
|
+
// poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
|
|
706
|
+
// caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
|
|
707
|
+
// exceed launch.sh's fast-fail threshold so a genuine failure exits PAST it — the retry loop then treats it as a
|
|
708
|
+
// real end, not a daemon race, and never sprays fresh (duplicate-prompt) threads.
|
|
709
|
+
export async function waitForCodexRollout(threadId: string, timeoutMs = 20000): Promise<boolean> {
|
|
710
|
+
const deadline = Date.now() + timeoutMs
|
|
711
|
+
for (;;) {
|
|
712
|
+
if (codexRolloutExists(threadId)) return true
|
|
713
|
+
if (Date.now() >= deadline) return false
|
|
714
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
509
718
|
// codex's deliver: use the Codex app-server JSON-RPC channel that also powers rich clients, never TUI typing.
|
|
510
719
|
// The visible TUI is launched against the same project app-server Unix socket, so this injects into the same
|
|
511
720
|
// thread the pane is showing — steering an in-progress turn or starting one if idle. A missing captured thread
|
|
@@ -544,6 +753,29 @@ export function writeManagedBlock(file: string, body: string, comment: readonly
|
|
|
544
753
|
writeFileSync(file, cur)
|
|
545
754
|
}
|
|
546
755
|
|
|
756
|
+
// the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
|
|
757
|
+
// leaving every other byte of the user's file intact. When deleteIfEmpty and nothing but whitespace remains,
|
|
758
|
+
// remove the file — it was WHOLLY ours (e.g. a CLAUDE.md that carried only the generated contract block). Same
|
|
759
|
+
// comment-style parameter so ONE primitive un-writes every managed file. No-op when the file/block is absent.
|
|
760
|
+
export function removeManagedBlock(file: string, comment: readonly [string, string] = ['<!-- ', ' -->'], deleteIfEmpty = false): void {
|
|
761
|
+
if (!existsSync(file)) return
|
|
762
|
+
const [open, close] = comment
|
|
763
|
+
const START = `${open}spexcode:start${close}`
|
|
764
|
+
const END = `${open}spexcode:end${close}`
|
|
765
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
766
|
+
const re = new RegExp(`\\n*${esc(START)}[\\s\\S]*?${esc(END)}\\n*`)
|
|
767
|
+
const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
768
|
+
if (!re.test(cur)) return
|
|
769
|
+
// remove ONLY our block plus the blank lines writeManagedBlock inserted around it; do NOT normalize the
|
|
770
|
+
// user's OWN whitespace elsewhere — this must leave every other byte intact so it is a faithful INVERSE of
|
|
771
|
+
// writeManagedBlock's append. A global `\n{3,}→\n\n` collapse used to sit here and mutated pre-existing
|
|
772
|
+
// blank-line runs in the user's file, which broke the private⇄default round-trip ([[private-overlay]]):
|
|
773
|
+
// default→private→default left a spurious one-line diff on a .gitignore that had internal blank lines.
|
|
774
|
+
const out = cur.replace(re, '\n').replace(/^\n+/, '')
|
|
775
|
+
if (deleteIfEmpty && !out.trim()) { rmSync(file, { force: true }); return }
|
|
776
|
+
writeFileSync(file, out)
|
|
777
|
+
}
|
|
778
|
+
|
|
547
779
|
// the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
|
|
548
780
|
// baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
|
|
549
781
|
// the cli-needing handlers + the gate's `spex materialize`.
|
|
@@ -576,10 +808,41 @@ export function codexHookHash(snakeEvent: string, command: string, timeout = 600
|
|
|
576
808
|
return 'sha256:' + createHash('sha256').update(JSON.stringify(canon(obj))).digest('hex')
|
|
577
809
|
}
|
|
578
810
|
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
811
|
+
// @@@ stripCodexTrustFor - remove EVERY prior definition of THIS project's codex trust from a config.toml body,
|
|
812
|
+
// in ANY form: our own sentinel block (whatever past format its comments used), a BARE `[projects."<proj>"]`
|
|
813
|
+
// table (codex AUTO-writes one the moment it trusts a folder interactively/`exec` — NOT sentinel-wrapped), and
|
|
814
|
+
// any `[hooks.state."<hooksJson>:…"]` tables. This is what makes the UNCONDITIONAL write duplicate-SAFE and
|
|
815
|
+
// SELF-HEALING: codex REFUSES to load a config.toml with a duplicate key ("duplicate key"), so a sentinel-only
|
|
816
|
+
// replace (the old behaviour) that missed a pre-existing bare/old block APPENDED a second `[projects."<proj>"]`
|
|
817
|
+
// and took codex fully OFFLINE (the real cause of the public-vps outage). It is TABLE-scoped and STRING-compared
|
|
818
|
+
// (no regex escaping of the path), so other projects' trust, the shared parent tables (`[projects]`,
|
|
819
|
+
// `[hooks.state]`), and every other config key are untouched; a skipped table's body ends at the next header,
|
|
820
|
+
// blank, or comment, so a user comment attached to a following table is preserved.
|
|
821
|
+
function stripCodexTrustFor(cur: string, proj: string, hooksJson: string): string {
|
|
822
|
+
const projHeader = `[projects."${proj}"]`
|
|
823
|
+
const hooksPrefix = `[hooks.state."${hooksJson}:`
|
|
824
|
+
const out: string[] = []
|
|
825
|
+
let skip = false
|
|
826
|
+
for (const line of cur.split('\n')) {
|
|
827
|
+
const t = line.trim()
|
|
828
|
+
const isHeader = /^\[\[?/.test(t) // a TOML table / array-of-tables header
|
|
829
|
+
if (skip) { if (t === '' || t.startsWith('#') || isHeader) skip = false; else continue } // end THIS table's body
|
|
830
|
+
if (isHeader && (t === projHeader || t.startsWith(hooksPrefix))) { skip = true; continue }
|
|
831
|
+
if (t === `# spexcode:trust:${proj} (managed — do not edit)` || t === `# spexcode:trust:end:${proj}`) continue
|
|
832
|
+
out.push(line)
|
|
833
|
+
}
|
|
834
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '')
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
|
|
838
|
+
// `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
|
|
839
|
+
// self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
|
|
840
|
+
// already-reviewed (no "Hooks need review" prompt on a persistent resume — see writeTrust). ALL prior
|
|
841
|
+
// definitions of this project's trust (ours, bare, or old-format) are STRIPPED first, so the write can never
|
|
842
|
+
// leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
|
|
843
|
+
// Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
|
|
844
|
+
// (`events` may be empty for a trust-only stamp in tests.)
|
|
845
|
+
export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): void {
|
|
583
846
|
const home = process.env.CODEX_HOME || join(homedir(), '.codex')
|
|
584
847
|
const file = join(home, 'config.toml')
|
|
585
848
|
const hooksJson = join(proj, '.codex', 'hooks.json')
|
|
@@ -589,13 +852,79 @@ function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: st
|
|
|
589
852
|
lines.push(`[hooks.state."${hooksJson}:${snake}:0:0"]`, `trusted_hash = "${codexHookHash(snake, cmdFor(e))}"`)
|
|
590
853
|
}
|
|
591
854
|
const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
|
|
592
|
-
const
|
|
593
|
-
let cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
594
|
-
const re = new RegExp(`# spexcode:trust:${esc} \\(managed[\\s\\S]*?# spexcode:trust:end:${esc}`)
|
|
595
|
-
if (re.test(cur)) cur = cur.replace(re, blk)
|
|
596
|
-
else cur = cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${blk}\n` : `${blk}\n`
|
|
855
|
+
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
|
|
597
856
|
if (!existsSync(home)) mkdirSync(home, { recursive: true })
|
|
598
|
-
writeFileSync(file,
|
|
857
|
+
writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
|
|
861
|
+
// removal writeCodexTrust does before it writes, so uninstall fully clears our trust (sentinel, bare, and
|
|
862
|
+
// hooks.state) and can never leave a half-block. No-op when the file/nothing-of-ours is absent (so it never
|
|
863
|
+
// rewrites/normalizes a config that carries none of our trust). CODEX_HOME respected for testability.
|
|
864
|
+
function removeCodexTrust(proj: string): void {
|
|
865
|
+
const home = process.env.CODEX_HOME || join(homedir(), '.codex')
|
|
866
|
+
const file = join(home, 'config.toml')
|
|
867
|
+
if (!existsSync(file)) return
|
|
868
|
+
const hooksJson = join(proj, '.codex', 'hooks.json')
|
|
869
|
+
const cur = readFileSync(file, 'utf8')
|
|
870
|
+
if (!cur.includes(`[projects."${proj}"]`) && !cur.includes(`[hooks.state."${hooksJson}:`) &&
|
|
871
|
+
!cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`)) return
|
|
872
|
+
const cleaned = stripCodexTrustFor(cur, proj, hooksJson)
|
|
873
|
+
writeFileSync(file, cleaned ? `${cleaned}\n` : '')
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// @@@ cleanHarness - the shared clean: the inverse of materialize's per-harness write, expressed PURELY
|
|
877
|
+
// through the adapter's own path methods so it can never drift from what write put there. Each step is
|
|
878
|
+
// surgical, gated on a SpexCode identity stamp: the contract files carry the managed-block sentinels; the shim
|
|
879
|
+
// is a generated file whose command line names our `dispatch.sh`; the trust is a sentinel-delimited config
|
|
880
|
+
// block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
|
|
881
|
+
// blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
|
|
882
|
+
// a sibling skill/agent the user added, and NEVER any .spec data.
|
|
883
|
+
function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
|
|
884
|
+
for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], true)
|
|
885
|
+
const shim = h.shimFile(proj)
|
|
886
|
+
if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
|
|
887
|
+
const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
|
|
888
|
+
if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh')) rmSync(anchor, { force: true })
|
|
889
|
+
h.removeTrust(proj)
|
|
890
|
+
const sd = h.skillDir(proj)
|
|
891
|
+
if (sd) for (const n of arts.skills) rmSync(join(sd, n), { recursive: true, force: true })
|
|
892
|
+
const ad = h.agentDir(proj)
|
|
893
|
+
if (ad) for (const n of arts.agents) rmSync(join(ad, `${n}.md`), { force: true })
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
897
|
+
// codex per-session liveness signal — a codex process live in the pane's DESCENDANT tree, NOT the pane's
|
|
898
|
+
// foreground command name, and NOT the shared app-server socket.
|
|
899
|
+
|
|
900
|
+
// @@@ paneTreeRunsCodex - the codex TUI is alive iff a codex-ish process is live SOMEWHERE in the launch
|
|
901
|
+
// pane's descendant process tree. The pane's FOREGROUND name is NOT the signal: the pane runs
|
|
902
|
+
// `bash <launch.sh>` → `bash -lc <codex script>` → node (the codex CLI) → the vendored `codex` binary, and
|
|
903
|
+
// tmux's `pane_current_command` reports the OUTERMOST of those — `bash` — for the entire life of a healthy,
|
|
904
|
+
// rendering TUI (field-confirmed on macmini and Linux). So "foreground == codex" false-read every live codex
|
|
905
|
+
// as offline, and the earlier sock-presence check false-read a dead one as online (the SHARED per-project
|
|
906
|
+
// app-server socket survives a failed `--remote resume`). The honest shape test: HEALTHY = codex (by whatever
|
|
907
|
+
// name — `codex`, the vendored musl binary, or the `node` its CLI runs under) exists among the pane pid's
|
|
908
|
+
// descendants; FAILED = the launch script's bounded retries exhausted, everything under the pane exited, and
|
|
909
|
+
// the pane sits at the bare shell — no codex/node anywhere below it. The walk is over ONE whole-box
|
|
910
|
+
// pid→(ppid, comm) snapshot the caller took (a single `ps` for the whole session list); missing probe data
|
|
911
|
+
// (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
|
|
912
|
+
// may not yet contain codex — as 'starting', not 'offline'.
|
|
913
|
+
const CODEXISH = /^(codex|node)/i // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
|
|
914
|
+
export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
|
|
915
|
+
if (!pane?.panePid || !pane.procs?.size) return false
|
|
916
|
+
const kids = new Map<number, number[]>()
|
|
917
|
+
for (const [pid, p] of pane.procs) {
|
|
918
|
+
const arr = kids.get(p.ppid); if (arr) arr.push(pid); else kids.set(p.ppid, [pid])
|
|
919
|
+
}
|
|
920
|
+
const stack = [...(kids.get(pane.panePid) ?? [])] // descendants only — the pane pid itself is the shell
|
|
921
|
+
while (stack.length) {
|
|
922
|
+
const pid = stack.pop()!
|
|
923
|
+
const comm = pane.procs.get(pid)?.comm ?? ''
|
|
924
|
+
if (CODEXISH.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
|
|
925
|
+
const c = kids.get(pid); if (c) stack.push(...c)
|
|
926
|
+
}
|
|
927
|
+
return false
|
|
599
928
|
}
|
|
600
929
|
|
|
601
930
|
// ---------------------------------------------------------------------------------------------------------
|
|
@@ -604,21 +933,34 @@ function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: st
|
|
|
604
933
|
const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
|
|
605
934
|
const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
606
935
|
|
|
936
|
+
// the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
|
|
937
|
+
// launchCmd and baseCmd so the two never diverge: `cmd` override wins, else ambient env→config→default.
|
|
938
|
+
const claudeBaseCmd = (cmd?: string) => cmd || process.env.SPEXCODE_CLAUDE_CMD || readConfig(mainCheckout()).sessions?.claudeCmd || 'claude --dangerously-skip-permissions'
|
|
939
|
+
const codexBaseCmd = (cmd?: string) => cmd || process.env.SPEXCODE_CODEX_CMD || readConfig(mainCheckout()).sessions?.codexCmd || 'codex --yolo'
|
|
940
|
+
|
|
607
941
|
export const claudeHarness: Harness = {
|
|
608
942
|
id: 'claude',
|
|
609
943
|
events: CLAUDE_EVENTS,
|
|
610
944
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
611
945
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
612
|
-
launchCmd: () =>
|
|
946
|
+
launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
|
|
947
|
+
baseCmd: claudeBaseCmd,
|
|
613
948
|
sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
|
|
614
949
|
sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
|
|
615
950
|
shimFile: (proj) => join(proj, '.claude', 'settings.json'),
|
|
951
|
+
worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
|
|
616
952
|
contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
|
|
617
953
|
skillDir: (proj) => join(proj, '.claude', 'skills'),
|
|
954
|
+
agentDir: (proj) => join(proj, '.claude', 'agents'),
|
|
618
955
|
shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
|
|
619
956
|
writeTrust: () => { /* Claude relies on folder-trust — nothing to write */ },
|
|
957
|
+
removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
|
|
958
|
+
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
620
959
|
slashCommands: claudeSlashCommands,
|
|
621
|
-
|
|
960
|
+
// online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
|
|
961
|
+
// the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
|
|
962
|
+
// dead-pane-reads-working bug). See rendezvousListening.
|
|
963
|
+
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
622
964
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
623
965
|
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
624
966
|
}
|
|
@@ -628,7 +970,8 @@ export const codexHarness: Harness = {
|
|
|
628
970
|
events: CODEX_EVENTS,
|
|
629
971
|
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
630
972
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
631
|
-
launchCmd: (id, runtimeDir) => codexLaunchCommand(id,
|
|
973
|
+
launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
|
|
974
|
+
baseCmd: codexBaseCmd,
|
|
632
975
|
sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
|
|
633
976
|
sessionEnvVar: 'CODEX_THREAD_ID',
|
|
634
977
|
// Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
|
|
@@ -639,14 +982,50 @@ export const codexHarness: Harness = {
|
|
|
639
982
|
// per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
|
|
640
983
|
// cwd, so one shared shim serves every worktree.
|
|
641
984
|
shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
|
|
985
|
+
// a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
|
|
986
|
+
// the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
|
|
987
|
+
// root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
|
|
988
|
+
// was never built). Its content is ignored (the rewrite reads the root's shim above), so it is a pure anchor.
|
|
989
|
+
// Only for a genuine worktree: on the main checkout, shimFile already wrote `.codex/hooks.json` there.
|
|
990
|
+
worktreeHookAnchor: (proj) => (mainCheckout(proj) === proj ? null : join(proj, '.codex', 'hooks.json')),
|
|
642
991
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')],
|
|
643
992
|
skillDir: (proj) => join(proj, '.codex', 'skills'),
|
|
993
|
+
agentDir: () => null, // codex has no file-discovered agent-definition primitive — materialize skips it
|
|
644
994
|
shim: (dispatch, spex) => buildShim('codex', CODEX_EVENTS, dispatch, spex),
|
|
995
|
+
// Write the FULL codex trust — BOTH tiers, UNCONDITIONALLY — because `bypass_hook_trust` covers neither on
|
|
996
|
+
// the dispatched-worker path:
|
|
997
|
+
// (1) PROJECT trust (`[projects."<mainCheckout>"] trust_level = "trusted"`) ENABLES the project config
|
|
998
|
+
// layer — the precondition for codex to DISCOVER our hooks AT ALL. codex-rs `get_layers` drops a
|
|
999
|
+
// disabled (untrusted) project layer BEFORE hook discovery runs, and bypass_hook_trust is read only
|
|
1000
|
+
// AFTER, per-handler — so it can NEVER enable a layer. A dispatched worker's app-server does NOT
|
|
1001
|
+
// auto-trust the project (only the interactive TUI / `codex exec` approval flow does), so without this
|
|
1002
|
+
// an untrusted worktree thread fires ZERO hooks ("Project-local config, hooks … are disabled until the
|
|
1003
|
+
// project is trusted").
|
|
1004
|
+
// (2) per-HOOK trust (the reverse-engineered `trusted_hash` blocks — codexHookHash) marks each hook Trusted
|
|
1005
|
+
// so it is NOT "new or changed". This is REQUIRED even though the launch carries
|
|
1006
|
+
// `--dangerously-bypass-hook-trust`: our visible TUI attaches to the backend-owned thread via `codex …
|
|
1007
|
+
// resume <tid>`, and codex-rs FORCES the startup hook-review prompt on a PERSISTENT RESUME regardless of
|
|
1008
|
+
// the flag (`bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume`,
|
|
1009
|
+
// tui/src/lib.rs) — an untrusted/modified hook (no matching hash) leaves the worker WEDGED at an
|
|
1010
|
+
// interactive "Hooks need review" menu. Matching hashes make review_needed_count == 0, so codex skips
|
|
1011
|
+
// the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
|
|
1012
|
+
// as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
|
|
1013
|
+
// thread still runs the hooks); it does not REPLACE the hashes here.
|
|
645
1014
|
writeTrust: (proj, cmdFor) => writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor),
|
|
1015
|
+
// trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
|
|
1016
|
+
removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
|
|
1017
|
+
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
646
1018
|
slashCommands: codexSlashCommands,
|
|
647
|
-
|
|
1019
|
+
// online iff the tmux window is up AND a codex-ish process is live in the pane pid's DESCENDANT tree — see
|
|
1020
|
+
// paneTreeRunsCodex. NOT the pane's foreground command (that is `bash`, the launch wrapper, even while the
|
|
1021
|
+
// TUI renders — the field-confirmed false-OFFLINE) and NOT the app-server socket (SHARED per-project, it
|
|
1022
|
+
// survives a failed `--remote resume` — the earlier false-ONLINE).
|
|
1023
|
+
liveness: (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && paneTreeRunsCodex(pane) ? 'online' : 'offline'),
|
|
648
1024
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
649
|
-
|
|
1025
|
+
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
1026
|
+
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
1027
|
+
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
1028
|
+
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
650
1029
|
}
|
|
651
1030
|
|
|
652
1031
|
// every adapter — materialize iterates this to render each harness's artifacts in one pass.
|
|
@@ -662,3 +1041,30 @@ export function harnessById(id: string): Harness {
|
|
|
662
1041
|
if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
|
|
663
1042
|
return h
|
|
664
1043
|
}
|
|
1044
|
+
|
|
1045
|
+
// --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
|
|
1046
|
+
// a launcher = a `{ harness, cmd }` pair in spexcode.json's `sessions.launchers`, keyed by a human-chosen name.
|
|
1047
|
+
// harness defaults to claude. resolveLauncher throws fail-loud on an unknown name (a session must never
|
|
1048
|
+
// silently launch under the wrong auth) and validates the harness id.
|
|
1049
|
+
export type Launcher = { name: string; harness: string; cmd: string }
|
|
1050
|
+
|
|
1051
|
+
// the configured launchers, as a stable name-sorted list (for the dashboard dropdown + the CLI). Empty when a
|
|
1052
|
+
// project configured none — the caller then falls back to the unnamed harness pick.
|
|
1053
|
+
export function launcherList(root = mainCheckout()): Launcher[] {
|
|
1054
|
+
const m = readConfig(root).sessions?.launchers || {}
|
|
1055
|
+
return Object.keys(m).sort().map((name) => ({ name, harness: m[name].harness || defaultHarness.id, cmd: m[name].cmd }))
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// the configured default launcher NAME ([[launcher-select]]) — the profile `spex new`/a dropdown pick with no
|
|
1059
|
+
// explicit choice resolves. '' when none configured. The dashboard reads this to PRE-SELECT its New-Session
|
|
1060
|
+
// dropdown to match the CLI/config default, so the two surfaces agree on which launcher a bare create uses.
|
|
1061
|
+
export function defaultLauncher(root = mainCheckout()): string {
|
|
1062
|
+
return readConfig(root).sessions?.defaultLauncher || ''
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
|
|
1066
|
+
const l = readConfig(root).sessions?.launchers?.[name]
|
|
1067
|
+
if (!l || !l.cmd) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
|
|
1068
|
+
harnessById(l.harness || defaultHarness.id) // validate the harness id fail-loud
|
|
1069
|
+
return { name, harness: l.harness || defaultHarness.id, cmd: l.cmd }
|
|
1070
|
+
}
|