spexcode 0.6.0 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/harness.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
-
import { join, dirname } from 'node:path'
|
|
2
|
+
import { join, dirname, basename } 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'
|
|
@@ -18,6 +18,8 @@ import { git } from './git.js'
|
|
|
18
18
|
import { shQuote } from './sh.js'
|
|
19
19
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
20
20
|
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
|
|
21
|
+
import { writeFileIfChanged } from './file-write.js'
|
|
22
|
+
import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace, type ExecutionTrace, type ExecutionTurn } from './execution-trace.js'
|
|
21
23
|
|
|
22
24
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
23
25
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -31,7 +33,7 @@ import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGenera
|
|
|
31
33
|
// payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
|
|
32
34
|
// (materialize writes every harness's artifacts).
|
|
33
35
|
|
|
34
|
-
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
36
|
+
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
35
37
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null; stopped?: boolean; archived?: boolean }
|
|
36
38
|
export type HarnessLaunchReadyRecord = HarnessLivenessRecord & { governed?: boolean; runtimeDir: string }
|
|
37
39
|
export type HarnessLaunchReadinessFence = {
|
|
@@ -148,7 +150,7 @@ export async function adapterLoadedReferenceState(
|
|
|
148
150
|
export interface Harness {
|
|
149
151
|
readonly id: HarnessId
|
|
150
152
|
// the id baked into the materialized shim. Headless variants reuse their native family's shim.
|
|
151
|
-
readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
|
|
153
|
+
readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode'
|
|
152
154
|
// whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
|
|
153
155
|
// adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
|
|
154
156
|
readonly headless: boolean
|
|
@@ -182,6 +184,9 @@ export interface Harness {
|
|
|
182
184
|
// instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
|
|
183
185
|
// is data on the adapter, not an `if (codex)` in sessions.ts.
|
|
184
186
|
readonly paneTitleIsSelfSummary: boolean
|
|
187
|
+
// The adapter-only native transcript reader. Its compact result has no raw envelope, argument, output, or
|
|
188
|
+
// reasoning data; product surfaces receive only the latest working note and typed tool steps.
|
|
189
|
+
executionTrace(threadId: string, turn: ExecutionTurn | null): ExecutionTrace | null
|
|
185
190
|
// --- launch / sessionId ---
|
|
186
191
|
// the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
|
|
187
192
|
// visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
|
|
@@ -522,7 +527,62 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
|
522
527
|
|
|
523
528
|
// The timeline is the message's copy, so rendezvous needs no receipt protocol. It writes one idempotent poke
|
|
524
529
|
// carrying the timeline mid and reports only whether that write reached the local transport.
|
|
525
|
-
|
|
530
|
+
type ClaudeForkTransport = { sock: string; auth: string }
|
|
531
|
+
|
|
532
|
+
// The backend need not share the agent's config root: an explicitly chosen launcher can point Claude at its
|
|
533
|
+
// own home. A moved source process remains alive by definition, so its one config-dir environment field is the
|
|
534
|
+
// live authority for locating the daemon roster. A stale/reused pid can at worst name a roster with no exact
|
|
535
|
+
// successor; it cannot select one without the moved/session-source checks below.
|
|
536
|
+
function claudeConfigRoots(sourceSessionId: string, runtimeDir?: string): string[] {
|
|
537
|
+
const roots: string[] = []
|
|
538
|
+
try {
|
|
539
|
+
const pidFile = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'agent.pid') : sessionArtifactPath(sourceSessionId, 'agent.pid')
|
|
540
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
|
541
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
542
|
+
const env = readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')
|
|
543
|
+
const config = env.find((entry) => entry.startsWith('CLAUDE_CONFIG_DIR='))?.slice('CLAUDE_CONFIG_DIR='.length)
|
|
544
|
+
if (config) roots.push(config)
|
|
545
|
+
}
|
|
546
|
+
} catch { /* source not available or this platform does not expose procfs */ }
|
|
547
|
+
if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
|
|
548
|
+
roots.push(join(homedir(), '.claude'))
|
|
549
|
+
return [...new Set(roots)]
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// A moved Claude conversation is a daemon-owned fork. Its launch-time socket still answers, but that process
|
|
553
|
+
// no longer returns to its prompt. A successor hook may have already recorded its exact Claude session id in
|
|
554
|
+
// `moved`; use that durable identity first, then retain the roster's source-transcript relation for deployments
|
|
555
|
+
// without the stamp. The roster remains the sole source of the live socket and current auth token. Keep this
|
|
556
|
+
// lookup Claude-local so other rendezvous adapters cannot adopt a coincidentally matching id.
|
|
557
|
+
function claudeForkTransport(sourceSessionId: string, runtimeDir?: string): ClaudeForkTransport | null {
|
|
558
|
+
const moved = (() => {
|
|
559
|
+
try {
|
|
560
|
+
const stamp = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'moved') : sessionArtifactPath(sourceSessionId, 'moved')
|
|
561
|
+
return readFileSync(stamp, 'utf8').trim()
|
|
562
|
+
} catch {
|
|
563
|
+
return ''
|
|
564
|
+
}
|
|
565
|
+
})()
|
|
566
|
+
for (const configDir of claudeConfigRoots(sourceSessionId, runtimeDir)) {
|
|
567
|
+
try {
|
|
568
|
+
const roster = JSON.parse(readFileSync(join(configDir, 'daemon', 'roster.json'), 'utf8')) as { workers?: Record<string, any> }
|
|
569
|
+
const workers = Object.values(roster.workers ?? {})
|
|
570
|
+
const usable = (worker: any) => typeof worker?.rendezvousSock === 'string' && typeof worker.rvAuth === 'string'
|
|
571
|
+
const recorded = workers.filter((worker) => usable(worker) && moved && worker?.sessionId === moved)
|
|
572
|
+
const candidates = recorded.length ? recorded : workers.filter((worker) => {
|
|
573
|
+
const launch = worker?.dispatch?.launch
|
|
574
|
+
if (launch?.mode !== 'resume' || launch.fork !== true || typeof launch.sessionId !== 'string') return false
|
|
575
|
+
const source = basename(launch.sessionId).replace(/\.jsonl$/, '')
|
|
576
|
+
return source === sourceSessionId && usable(worker)
|
|
577
|
+
})
|
|
578
|
+
const worker = candidates.sort((a, b) => Number(b.startedAt ?? 0) - Number(a.startedAt ?? 0))[0]
|
|
579
|
+
if (worker) return { sock: worker.rendezvousSock, auth: worker.rvAuth }
|
|
580
|
+
} catch { /* this Claude config has no readable daemon roster */ }
|
|
581
|
+
}
|
|
582
|
+
return null
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function replyViaSocket(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
|
|
526
586
|
return new Promise((resolve) => {
|
|
527
587
|
let settled = false
|
|
528
588
|
let c: ReturnType<typeof createConnection>
|
|
@@ -544,7 +604,7 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
|
|
|
544
604
|
})
|
|
545
605
|
c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }))
|
|
546
606
|
c.on('connect', () => c.write(
|
|
547
|
-
JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })
|
|
607
|
+
`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`,
|
|
548
608
|
(error) => {
|
|
549
609
|
if (error) return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` })
|
|
550
610
|
c.end()
|
|
@@ -554,16 +614,32 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
|
|
|
554
614
|
})
|
|
555
615
|
}
|
|
556
616
|
const POKE_ATTEMPTS = 2
|
|
557
|
-
|
|
558
|
-
const sock = rvSock(id)
|
|
617
|
+
async function pokeRendezvous(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
|
|
559
618
|
let last: DispatchResult = { ok: false, error: 'not attempted' }
|
|
560
619
|
for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
|
|
561
|
-
last = await replyViaSocket(sock, text, mid)
|
|
620
|
+
last = await replyViaSocket(sock, text, mid, auth)
|
|
562
621
|
if (last.ok) return last
|
|
563
622
|
}
|
|
564
623
|
return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` }
|
|
565
624
|
}
|
|
566
625
|
|
|
626
|
+
export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
|
|
627
|
+
return pokeRendezvous(rvSock(id), text, mid)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export async function deliverViaClaudeRendezvous(id: string, text: string, mid?: string, runtimeDir?: string): Promise<DispatchResult> {
|
|
631
|
+
const fork = claudeForkTransport(id, runtimeDir)
|
|
632
|
+
const sourceSock = rvSock(id)
|
|
633
|
+
if (!fork) return pokeRendezvous(sourceSock, text, mid)
|
|
634
|
+
|
|
635
|
+
const forkResult = await pokeRendezvous(fork.sock, text, mid, fork.auth)
|
|
636
|
+
if (forkResult.ok || fork.sock === sourceSock) return forkResult
|
|
637
|
+
|
|
638
|
+
const sourceResult = await pokeRendezvous(sourceSock, text, mid)
|
|
639
|
+
if (sourceResult.ok) return sourceResult
|
|
640
|
+
return { ok: false, error: `fork rendezvous failed: ${forkResult.error}; source fallback failed: ${sourceResult.error}` }
|
|
641
|
+
}
|
|
642
|
+
|
|
567
643
|
export async function deliverViaSocketOrWake(
|
|
568
644
|
id: string,
|
|
569
645
|
text: string,
|
|
@@ -702,10 +778,12 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
|
|
|
702
778
|
// thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
|
|
703
779
|
`export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
|
|
704
780
|
// The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
|
|
705
|
-
// resume resolves its existing session/thread binding
|
|
706
|
-
// replacement
|
|
781
|
+
// resume resolves its existing session/thread binding, so a LIVE root never has its conversation moved to a
|
|
782
|
+
// replacement. Both spellings carry the server command because either may be the launch that has to start a
|
|
783
|
+
// root: after a host restart the bound generation is a corpse, and resume rebuilds one to load the same
|
|
784
|
+
// on-disk rollout. It prints only shell assignments for the exact proven endpoint.
|
|
707
785
|
'if [ "$1" = "--resume" ]; then',
|
|
708
|
-
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" )" || exit 1`,
|
|
786
|
+
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" ${shQuote(server)} )" || exit 1`,
|
|
709
787
|
'else',
|
|
710
788
|
` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
|
|
711
789
|
'fi',
|
|
@@ -902,50 +980,61 @@ export function codexTurnFailureObserver(
|
|
|
902
980
|
return { close: () => finish(null), closed }
|
|
903
981
|
}
|
|
904
982
|
|
|
905
|
-
// Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
906
|
-
// defines thread/archive and thread/unarchive with {threadId}
|
|
983
|
+
// Protocol-verified cold/restore/control seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
984
|
+
// defines thread/archive and thread/unarchive with {threadId}, plus turn/interrupt with {threadId, turnId}; no
|
|
985
|
+
// guessed method or process command is used.
|
|
907
986
|
type CodexGenerationFence = { dir: string; endpoint: CodexGenerationEndpoint; generation: string }
|
|
908
|
-
|
|
987
|
+
// A failed mutation says whether the server can still commit it. `refused` means the request never reached the
|
|
988
|
+
// server or the server answered by rejecting it, so the target is provably unchanged and compensation is safe.
|
|
989
|
+
// `unknown` means the request was sent and no verdict came back — the server may still be executing it, so
|
|
990
|
+
// sending anything else down the same connection queues behind that work and fails too.
|
|
991
|
+
type CodexMutationOutcome = { ok: true } | { ok: false; error: string; commit: 'refused' | 'unknown' }
|
|
992
|
+
function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive' | 'turn/interrupt', threadId: string, fence?: CodexGenerationFence, turnId?: string, budgetMs = CODEX_MUTATION_BASE_MS): Promise<CodexMutationOutcome> {
|
|
909
993
|
const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
|
|
910
994
|
? `Codex ${method} refused because the shared app-server generation changed`
|
|
911
995
|
: null
|
|
912
996
|
const before = generationError()
|
|
913
|
-
if (before) return Promise.resolve({ ok: false, error: before })
|
|
997
|
+
if (before) return Promise.resolve({ ok: false, error: before, commit: 'refused' })
|
|
998
|
+
if (method === 'turn/interrupt' && !turnId)
|
|
999
|
+
return Promise.resolve({ ok: false, error: 'Codex turn interrupt needs an exact turn id', commit: 'refused' })
|
|
914
1000
|
return new Promise((resolve) => {
|
|
915
1001
|
const conn: Socket = createConnection(sock)
|
|
916
1002
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
917
|
-
let upgraded = false, settled = false
|
|
918
|
-
const done = (r:
|
|
1003
|
+
let upgraded = false, settled = false, requested = false
|
|
1004
|
+
const done = (r: CodexMutationOutcome) => {
|
|
919
1005
|
if (settled) return
|
|
920
1006
|
settled = true
|
|
921
1007
|
clearTimeout(timer)
|
|
922
1008
|
try { conn.destroy() } catch {}
|
|
923
1009
|
resolve(r)
|
|
924
1010
|
}
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
conn.on('
|
|
1011
|
+
// Once the request is on the wire the server owns it, so every unanswered end — timeout, socket error,
|
|
1012
|
+
// early close, a generation swap — leaves the commit unknown rather than refused.
|
|
1013
|
+
const unanswered = (error: string) => done({ ok: false, error, commit: requested ? 'unknown' : 'refused' })
|
|
1014
|
+
const timer = setTimeout(() => unanswered(generationError() || `Codex ${method} did not answer within ${budgetMs}ms`), budgetMs)
|
|
1015
|
+
conn.on('error', (e) => unanswered(generationError() || `Codex ${method} connection failed: ${rpcError(e)}`))
|
|
1016
|
+
conn.on('close', () => { if (!settled) unanswered(`Codex app-server closed during ${method}`) })
|
|
930
1017
|
const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
|
|
931
1018
|
conn.on('connect', () => {
|
|
932
1019
|
const changed = generationError()
|
|
933
|
-
if (changed) return
|
|
1020
|
+
if (changed) return unanswered(changed)
|
|
934
1021
|
conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
|
|
935
1022
|
})
|
|
936
1023
|
const handle = (json: string) => {
|
|
937
1024
|
let m: JsonRpc
|
|
938
1025
|
try { m = JSON.parse(json) } catch { return }
|
|
939
|
-
|
|
1026
|
+
// The server answered by rejecting, so the target is provably unchanged whether or not we had sent it.
|
|
1027
|
+
if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}`, commit: 'refused' })
|
|
940
1028
|
if (m.id === 1 && m.result) {
|
|
941
1029
|
const changed = generationError()
|
|
942
|
-
if (changed) return
|
|
1030
|
+
if (changed) return unanswered(changed)
|
|
943
1031
|
send({ method: 'initialized', params: {} })
|
|
944
|
-
|
|
1032
|
+
requested = true
|
|
1033
|
+
return send({ id: 2, method, params: method === 'turn/interrupt' ? { threadId, turnId } : { threadId } })
|
|
945
1034
|
}
|
|
946
1035
|
if (m.id === 2 && m.result) {
|
|
947
1036
|
const changed = generationError()
|
|
948
|
-
return changed ?
|
|
1037
|
+
return changed ? unanswered(changed) : done({ ok: true })
|
|
949
1038
|
}
|
|
950
1039
|
}
|
|
951
1040
|
conn.on('data', (chunk: Buffer) => {
|
|
@@ -954,17 +1043,32 @@ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/un
|
|
|
954
1043
|
const i = fs.buf.indexOf('\r\n\r\n')
|
|
955
1044
|
if (i < 0) return
|
|
956
1045
|
const head = fs.buf.slice(0, i).toString('utf8')
|
|
957
|
-
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}
|
|
1046
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}`, commit: 'refused' })
|
|
958
1047
|
upgraded = true
|
|
959
1048
|
fs.buf = fs.buf.slice(i + 4)
|
|
960
1049
|
send(wsInitialize)
|
|
961
1050
|
}
|
|
962
|
-
if (drainWsFrames(fs, conn, handle))
|
|
1051
|
+
if (drainWsFrames(fs, conn, handle)) unanswered(`Codex app-server closed during ${method}`)
|
|
963
1052
|
})
|
|
964
1053
|
})
|
|
965
1054
|
}
|
|
966
1055
|
|
|
967
1056
|
type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
|
|
1057
|
+
// Dashboard/resource probes keep their own short budget; this target-scoped census is only entered by a
|
|
1058
|
+
// lifecycle mutation that already holds the session transition lock and must tolerate a busy app-server.
|
|
1059
|
+
const CODEX_MUTATION_CENSUS_MS = 15_000
|
|
1060
|
+
// A mutation's response budget. `thread/unarchive` and `turn/interrupt` are state flips the server answers at
|
|
1061
|
+
// once — measured 36ms to unarchive the very same 279 MB thread that took 47.7s to archive — so they keep the
|
|
1062
|
+
// base. `thread/archive` on a LOADED thread differs in kind: the server flushes that thread's whole in-memory
|
|
1063
|
+
// rollout inside shutdown_and_wait before it commits, so the wait is proportional to accumulated history
|
|
1064
|
+
// (measured 47.7s for 279 MB, ~5.9 MB/s, against ~1.5s for a notLoaded member that flushes nothing). A fixed
|
|
1065
|
+
// ceiling therefore never bounds the operation; it only picks the transcript size above which archive stops
|
|
1066
|
+
// working, and raising it just moves that size. The scaled term is deliberately pessimistic — a floor rate ~6x
|
|
1067
|
+
// under the measured one — because its job is to catch a WEDGED server, not to predict a flush: a machine
|
|
1068
|
+
// several times slower still archives, while a hung one still fails loudly.
|
|
1069
|
+
const CODEX_MUTATION_BASE_MS = 15_000
|
|
1070
|
+
const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000
|
|
1071
|
+
const codexArchiveBudgetMs = (bytes: number) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS)
|
|
968
1072
|
// Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
|
|
969
1073
|
// entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
|
|
970
1074
|
// supplies every protocol source kind explicitly for its thread/list calls.
|
|
@@ -979,6 +1083,7 @@ function codexPagedIds(
|
|
|
979
1083
|
extractId: (item: unknown) => string | null,
|
|
980
1084
|
label: string,
|
|
981
1085
|
onItem?: (item: unknown) => void,
|
|
1086
|
+
timeoutMs = CODEX_MUTATION_CENSUS_MS,
|
|
982
1087
|
): Promise<CodexPagedIdsResult> {
|
|
983
1088
|
return new Promise((resolve) => {
|
|
984
1089
|
const conn: Socket = createConnection(sock)
|
|
@@ -989,7 +1094,7 @@ function codexPagedIds(
|
|
|
989
1094
|
if (settled) return
|
|
990
1095
|
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
991
1096
|
}
|
|
992
|
-
const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out` }),
|
|
1097
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out after ${timeoutMs}ms` }), timeoutMs)
|
|
993
1098
|
conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
|
|
994
1099
|
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
|
|
995
1100
|
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
@@ -1038,18 +1143,23 @@ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true;
|
|
|
1038
1143
|
return result.ok ? { ok: true, referenceIds: result.ids } : result
|
|
1039
1144
|
}
|
|
1040
1145
|
|
|
1041
|
-
const
|
|
1146
|
+
const CODEX_RUNNING_TURN_READ_MS = 15_000
|
|
1042
1147
|
|
|
1043
|
-
|
|
1148
|
+
// @@@ presence vs identity - two different questions, deliberately not one helper.
|
|
1149
|
+
// A gate asks "is a turn in flight right now"; thread/list answers that for every thread at once, at a cost
|
|
1150
|
+
// that tracks the thread COUNT. Interrupt must additionally name the turn to interrupt, and only a turn read
|
|
1151
|
+
// carries the id — a cost that tracks that one thread's persisted HISTORY. So this read stays for interrupt,
|
|
1152
|
+
// where the target is by definition active and short-lived, and no gate may be routed back through it.
|
|
1153
|
+
function codexRunningTurn(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }> {
|
|
1044
1154
|
return new Promise((resolve) => {
|
|
1045
1155
|
const conn: Socket = createConnection(sock)
|
|
1046
1156
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
1047
1157
|
let upgraded = false, settled = false
|
|
1048
|
-
const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }) => {
|
|
1158
|
+
const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }) => {
|
|
1049
1159
|
if (settled) return
|
|
1050
1160
|
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
1051
1161
|
}
|
|
1052
|
-
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${
|
|
1162
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${CODEX_RUNNING_TURN_READ_MS}ms` }), CODEX_RUNNING_TURN_READ_MS)
|
|
1053
1163
|
conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }))
|
|
1054
1164
|
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }) })
|
|
1055
1165
|
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
@@ -1067,7 +1177,12 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
|
|
|
1067
1177
|
if (message.id !== 2 || !message.result) return
|
|
1068
1178
|
const turns = (message.result as { data?: unknown }).data
|
|
1069
1179
|
if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
|
|
1070
|
-
|
|
1180
|
+
const active = turns.find((turn): turn is { id?: unknown; status?: unknown } =>
|
|
1181
|
+
!!turn && typeof turn === 'object' && (turn as { status?: unknown }).status === 'inProgress')
|
|
1182
|
+
if (!active) return done({ ok: true, turnPresence: 'idle' })
|
|
1183
|
+
return typeof active.id === 'string' && active.id
|
|
1184
|
+
? done({ ok: true, turnPresence: 'active', turnId: active.id })
|
|
1185
|
+
: done({ ok: true, turnPresence: 'unknown' })
|
|
1071
1186
|
}
|
|
1072
1187
|
conn.on('data', (chunk: Buffer) => {
|
|
1073
1188
|
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
@@ -1083,14 +1198,57 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
|
|
|
1083
1198
|
})
|
|
1084
1199
|
}
|
|
1085
1200
|
|
|
1201
|
+
const CODEX_INTERRUPT_SETTLE_MS = 15_000
|
|
1202
|
+
|
|
1203
|
+
async function interruptCodexTurn(rec: HarnessDeliveryRecord): Promise<DispatchResult> {
|
|
1204
|
+
if (!rec.harnessSessionId) return { ok: false, error: 'no exact Codex thread identity is registered' }
|
|
1205
|
+
const threadId = rec.harnessSessionId
|
|
1206
|
+
const dir = rec.runtimeDir || runtimeRoot()
|
|
1207
|
+
const endpoint = codexEndpointForRecord(rec, dir)
|
|
1208
|
+
if (!endpoint) return { ok: false, error: 'no exact Codex generation binding is registered for this target' }
|
|
1209
|
+
const generation = codexRuntimeGeneration(dir, endpoint)
|
|
1210
|
+
if (!generation) return { ok: false, error: 'Codex shared app-server generation is unproven' }
|
|
1211
|
+
const fence = { dir, endpoint, generation }
|
|
1212
|
+
const before = await codexRunningTurn(endpoint.socketPath, threadId)
|
|
1213
|
+
if (!before.ok) return { ok: false, error: before.error }
|
|
1214
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1215
|
+
return { ok: false, error: 'shared Codex app-server generation changed during interrupt preflight' }
|
|
1216
|
+
if (before.turnPresence === 'idle') return { ok: true }
|
|
1217
|
+
if (before.turnPresence !== 'active' || !before.turnId)
|
|
1218
|
+
return { ok: false, error: `Codex target thread ${threadId} turn state is unknown` }
|
|
1219
|
+
const interrupted = await codexThreadMutation(endpoint.socketPath, 'turn/interrupt', threadId, fence, before.turnId)
|
|
1220
|
+
if (!interrupted.ok) return { ok: false, error: interrupted.error }
|
|
1221
|
+
const deadline = Date.now() + CODEX_INTERRUPT_SETTLE_MS
|
|
1222
|
+
for (;;) {
|
|
1223
|
+
const after = await codexRunningTurn(endpoint.socketPath, threadId)
|
|
1224
|
+
if (!after.ok) return { ok: false, error: after.error }
|
|
1225
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1226
|
+
return { ok: false, error: 'shared Codex app-server generation changed during interrupt settlement' }
|
|
1227
|
+
if (after.turnPresence === 'idle') return { ok: true }
|
|
1228
|
+
if (after.turnPresence === 'unknown') return { ok: false, error: `Codex target thread ${threadId} turn state is unknown after interrupt` }
|
|
1229
|
+
if (Date.now() >= deadline) return { ok: false, error: `Codex target thread ${threadId} remained active after interrupt` }
|
|
1230
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1086
1234
|
// The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
|
|
1087
1235
|
// a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
|
|
1088
1236
|
export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
|
|
1089
1237
|
return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
|
|
1090
1238
|
}
|
|
1091
1239
|
|
|
1240
|
+
// Every thread/list row carries the app-server's live turn state for that thread, in the protocol's own
|
|
1241
|
+
// three variants. `notLoaded` duplicates what thread/loaded/list reports; `idle`/`active` answer the only
|
|
1242
|
+
// question a lifecycle gate asks. Any other shape is `unknown` and fails closed — never derived from
|
|
1243
|
+
// something cheaper.
|
|
1244
|
+
type CodexThreadStatus = 'notLoaded' | 'idle' | 'active' | 'unknown'
|
|
1245
|
+
const codexRowStatus = (row: { status?: unknown }): CodexThreadStatus => {
|
|
1246
|
+
const type = (row.status as { type?: unknown } | null | undefined)?.type
|
|
1247
|
+
return type === 'notLoaded' || type === 'idle' || type === 'active' ? type : 'unknown'
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1092
1250
|
type CodexThreadCollectionResult =
|
|
1093
|
-
| { ok: true; ids: string[]; parentById: Map<string, string | null> }
|
|
1251
|
+
| { ok: true; ids: string[]; parentById: Map<string, string | null>; statusById: Map<string, CodexThreadStatus> }
|
|
1094
1252
|
| { ok: false; error: string }
|
|
1095
1253
|
|
|
1096
1254
|
function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
|
|
@@ -1098,6 +1256,7 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
|
|
|
1098
1256
|
? params.sourceKinds
|
|
1099
1257
|
: [...CODEX_THREAD_SOURCE_KINDS]
|
|
1100
1258
|
const parentById = new Map<string, string | null>()
|
|
1259
|
+
const statusById = new Map<string, CodexThreadStatus>()
|
|
1101
1260
|
const conflictingParents = new Set<string>()
|
|
1102
1261
|
return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
|
|
1103
1262
|
if (typeof item === 'string') return item
|
|
@@ -1105,26 +1264,40 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
|
|
|
1105
1264
|
return typeof id === 'string' ? id : null
|
|
1106
1265
|
}, 'thread/list', (item) => {
|
|
1107
1266
|
if (!item || typeof item !== 'object') return
|
|
1108
|
-
const row = item as { id?: unknown; parentThreadId?: unknown }
|
|
1267
|
+
const row = item as { id?: unknown; parentThreadId?: unknown; status?: unknown }
|
|
1109
1268
|
if (typeof row.id !== 'string') return
|
|
1110
1269
|
const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
|
|
1111
1270
|
if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
|
|
1112
1271
|
parentById.set(row.id, parent)
|
|
1272
|
+
// Parent ownership is a fact about the graph, so a disagreement across pages is a census fault.
|
|
1273
|
+
// Turn state is live, so a mid-drain change is not a fault — it is simply no longer knowable here.
|
|
1274
|
+
const status = codexRowStatus(row)
|
|
1275
|
+
statusById.set(row.id, statusById.has(row.id) && statusById.get(row.id) !== status ? 'unknown' : status)
|
|
1113
1276
|
}).then((result) => {
|
|
1114
1277
|
if (!result.ok) return result
|
|
1115
1278
|
if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
|
|
1116
|
-
return { ...result, parentById }
|
|
1279
|
+
return { ...result, parentById, statusById }
|
|
1117
1280
|
})
|
|
1118
1281
|
}
|
|
1119
1282
|
|
|
1283
|
+
// The gate's question is about the tip — is a turn in flight right now — and thread/list already answers it
|
|
1284
|
+
// for every thread at once, at a cost that tracks how many threads exist. Reading turns instead costs the
|
|
1285
|
+
// target's whole persisted history against a fixed budget, so a long-lived session becomes unmutatable.
|
|
1286
|
+
const codexPresenceFromStatus = (status: CodexThreadStatus | undefined): SharedRuntimeMutationGuard['targetTurnPresence'] =>
|
|
1287
|
+
status === 'idle' || status === 'active' ? status : 'unknown'
|
|
1288
|
+
|
|
1120
1289
|
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeMutationGuard> {
|
|
1121
1290
|
const generationBefore = codexMutationGeneration(dir, endpoint)
|
|
1122
1291
|
if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
|
|
1123
1292
|
const sock = endpoint.socketPath
|
|
1124
|
-
|
|
1293
|
+
// The descendant collections are ancestor-filtered and therefore exclude the target itself, so the
|
|
1294
|
+
// target's own turn state comes from the whole-collection census. These run concurrently with the rest.
|
|
1295
|
+
const [loaded, activeDescendants, archivedDescendants, activeList, archivedList] = await Promise.all([
|
|
1125
1296
|
codexLoadedReferenceIds(sock),
|
|
1126
1297
|
codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1127
1298
|
codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1299
|
+
codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
|
|
1300
|
+
codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
|
|
1128
1301
|
])
|
|
1129
1302
|
const referenceIds = loaded.ok ? loaded.referenceIds : []
|
|
1130
1303
|
const descendantIds = activeDescendants.ok && archivedDescendants.ok
|
|
@@ -1133,12 +1306,11 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), e
|
|
|
1133
1306
|
if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
|
|
1134
1307
|
if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
|
|
1135
1308
|
if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
|
|
1136
|
-
|
|
1137
|
-
if (
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1309
|
+
if (!activeList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeList.error }
|
|
1310
|
+
if (!archivedList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedList.error }
|
|
1311
|
+
const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = referenceIds.includes(threadId)
|
|
1312
|
+
? codexPresenceFromStatus(activeList.statusById.get(threadId) ?? archivedList.statusById.get(threadId))
|
|
1313
|
+
: 'none'
|
|
1142
1314
|
if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
|
|
1143
1315
|
return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
|
|
1144
1316
|
return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
|
|
@@ -1181,7 +1353,7 @@ function isEndpointLike(value: unknown): value is CodexGenerationEndpoint {
|
|
|
1181
1353
|
typeof (value as CodexGenerationEndpoint).socketPath === 'string'
|
|
1182
1354
|
}
|
|
1183
1355
|
|
|
1184
|
-
async function
|
|
1356
|
+
async function codexColdPreflightOnce(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
|
|
1185
1357
|
const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint)
|
|
1186
1358
|
if (!generation || codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1187
1359
|
return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
|
|
@@ -1190,8 +1362,8 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1190
1362
|
codexLoadedReferenceIds(sock),
|
|
1191
1363
|
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1192
1364
|
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1193
|
-
|
|
1194
|
-
|
|
1365
|
+
codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
|
|
1366
|
+
codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
|
|
1195
1367
|
])
|
|
1196
1368
|
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1197
1369
|
return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
|
|
@@ -1243,18 +1415,20 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1243
1415
|
}
|
|
1244
1416
|
}
|
|
1245
1417
|
|
|
1418
|
+
// Every subtree member was just proven to occur in exactly one whole-collection census, so that census
|
|
1419
|
+
// already carries each one's live turn state. No second round of native reads, and therefore no second
|
|
1420
|
+
// generation fence — nothing was read between the fence above and here.
|
|
1421
|
+
const statusById = new Map([...activeList.statusById, ...archivedList.statusById])
|
|
1246
1422
|
const loadedSet = new Set(loaded.referenceIds)
|
|
1247
1423
|
const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
return { ok: false, reason:
|
|
1251
|
-
|
|
1252
|
-
if (!state.ok) return { ok: false, reason: state.error }
|
|
1253
|
-
if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
|
|
1254
|
-
if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
|
|
1424
|
+
for (const id of loadedSubtreeIds) {
|
|
1425
|
+
const presence = codexPresenceFromStatus(statusById.get(id))
|
|
1426
|
+
if (presence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
|
|
1427
|
+
if (presence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
|
|
1255
1428
|
if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
|
|
1256
1429
|
}
|
|
1257
1430
|
|
|
1431
|
+
// Proven, not assumed: a loaded target is one of the members the loop above just cleared.
|
|
1258
1432
|
const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
|
|
1259
1433
|
const guard: SharedRuntimeMutationGuard = {
|
|
1260
1434
|
healthy: true,
|
|
@@ -1283,6 +1457,28 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1283
1457
|
return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
|
|
1284
1458
|
}
|
|
1285
1459
|
|
|
1460
|
+
// A busy app-server can refuse one WebSocket census while accepting the next. The refusal is transport-local,
|
|
1461
|
+
// so retry the complete proof (including generation fencing) within the terminal operation's finite budget;
|
|
1462
|
+
// semantic ownership refusals still return immediately and never turn into repeated native reads.
|
|
1463
|
+
const CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS = 6
|
|
1464
|
+
const CODEX_COLD_PREFLIGHT_RETRY_MS = 250
|
|
1465
|
+
const CODEX_COLD_PREFLIGHT_DEADLINE_MS = 30_000
|
|
1466
|
+
const isTransientCodexCensusFailure = (reason: string): boolean =>
|
|
1467
|
+
/(?:timed out|connection|closed during|refused .*census|census failed|app-server busy)/i.test(reason)
|
|
1468
|
+
|
|
1469
|
+
async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
|
|
1470
|
+
const deadline = Date.now() + CODEX_COLD_PREFLIGHT_DEADLINE_MS
|
|
1471
|
+
for (let attempt = 0; attempt < CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS; attempt++) {
|
|
1472
|
+
const result = await codexColdPreflightOnce(threadId, dir, expectedGeneration, endpoint)
|
|
1473
|
+
if (result.ok || !isTransientCodexCensusFailure(result.reason) || attempt === CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS - 1) return result
|
|
1474
|
+
const remaining = deadline - Date.now()
|
|
1475
|
+
if (remaining <= 0) return result
|
|
1476
|
+
const delay = Math.min(CODEX_COLD_PREFLIGHT_RETRY_MS * 2 ** attempt, remaining)
|
|
1477
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
1478
|
+
}
|
|
1479
|
+
throw new Error('unreachable Codex cold preflight retry state')
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1286
1482
|
async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine> {
|
|
1287
1483
|
const dir = runtimeRoot()
|
|
1288
1484
|
const generation = codexMutationGeneration(dir)
|
|
@@ -1312,8 +1508,16 @@ async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSe
|
|
|
1312
1508
|
return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
|
|
1313
1509
|
const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
|
|
1314
1510
|
const legacy = legacyCodexGenerationEndpoint(dir)
|
|
1315
|
-
|
|
1316
|
-
|
|
1511
|
+
// Quarantine archives one exact orphan, so it pays the same flush a subtree member does when that orphan is
|
|
1512
|
+
// loaded; the budget is derived the same way rather than being a second, differently-wrong constant.
|
|
1513
|
+
let orphanBudgetMs = CODEX_MUTATION_BASE_MS
|
|
1514
|
+
if (plan.guard.referenceIds.includes(threadId)) {
|
|
1515
|
+
const rollout = codexRolloutBytes(threadId)
|
|
1516
|
+
if ('unreadable' in rollout) return { ok: false, reason: `Codex native thread ${threadId} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown` }
|
|
1517
|
+
orphanBudgetMs = codexArchiveBudgetMs(rollout.bytes)
|
|
1518
|
+
}
|
|
1519
|
+
const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation }, undefined, orphanBudgetMs)
|
|
1520
|
+
if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}${archived.commit === 'unknown' ? '; commit state is unknown' : ''}` }
|
|
1317
1521
|
const after = await codexColdPreflight(threadId, dir, generation)
|
|
1318
1522
|
const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
|
|
1319
1523
|
if (!after.ok) {
|
|
@@ -1742,19 +1946,25 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
|
|
|
1742
1946
|
// thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
|
|
1743
1947
|
// rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
|
|
1744
1948
|
// found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
|
|
1745
|
-
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
|
|
1746
1949
|
// does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
|
|
1747
1950
|
// (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
|
|
1748
1951
|
// the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
|
|
1749
1952
|
// dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
|
|
1750
1953
|
// every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
|
|
1751
1954
|
// "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
|
|
1752
|
-
export function codexRolloutExists(threadId: string, root
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1955
|
+
export function codexRolloutExists(threadId: string, root?: string): boolean {
|
|
1956
|
+
return codexRolloutPath(threadId, root) !== null
|
|
1957
|
+
}
|
|
1958
|
+
// The same day-dir walk, answering how big that rollout is. `thread/archive` on a LOADED thread flushes the
|
|
1959
|
+
// thread's in-memory rollout inside the server's shutdown_and_wait before it commits, so this size IS the work
|
|
1960
|
+
// an archive asks for; a notLoaded thread flushes nothing and its size is irrelevant. NO rollout file is a real
|
|
1961
|
+
// `0`, not an error: a thread that has started but not yet persisted (thread/start alone writes none, and a
|
|
1962
|
+
// fresh app-server lags 2-4s) has nothing to flush, so refusing it would be a false refusal. Only a file that
|
|
1963
|
+
// exists and cannot be measured is unreadable, and that fails closed rather than passing as small.
|
|
1964
|
+
export function codexRolloutBytes(threadId: string, root?: string): { bytes: number } | { unreadable: true } {
|
|
1965
|
+
const path = codexRolloutPath(threadId, root)
|
|
1966
|
+
if (path) { try { return { bytes: statSync(path).size } } catch { return { unreadable: true } } }
|
|
1967
|
+
return { bytes: 0 }
|
|
1758
1968
|
}
|
|
1759
1969
|
// poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
|
|
1760
1970
|
// caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
|
|
@@ -1797,17 +2007,16 @@ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string
|
|
|
1797
2007
|
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
1798
2008
|
// comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
|
|
1799
2009
|
// (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
|
|
1800
|
-
export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']):
|
|
2010
|
+
export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): boolean {
|
|
1801
2011
|
const [open, close] = comment
|
|
1802
2012
|
const START = `${open}spexcode:start${close}`
|
|
1803
2013
|
const END = `${open}spexcode:end${close}`
|
|
1804
2014
|
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1805
2015
|
const block = `${START}\n${body}\n${END}`
|
|
1806
|
-
|
|
2016
|
+
const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
1807
2017
|
const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`)
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
writeFileSync(file, cur)
|
|
2018
|
+
const next = re.test(cur) ? cur.replace(re, block) : cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
|
|
2019
|
+
return writeFileIfChanged(file, next)
|
|
1811
2020
|
}
|
|
1812
2021
|
|
|
1813
2022
|
// the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
|
|
@@ -1916,7 +2125,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
|
|
|
1916
2125
|
const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
|
|
1917
2126
|
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
|
|
1918
2127
|
if (!existsSync(home)) mkdirSync(home, { recursive: true })
|
|
1919
|
-
|
|
2128
|
+
writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
|
|
1920
2129
|
return file
|
|
1921
2130
|
}
|
|
1922
2131
|
|
|
@@ -2025,6 +2234,9 @@ const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToo
|
|
|
2025
2234
|
// agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
|
|
2026
2235
|
// real gap as codex.
|
|
2027
2236
|
const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
2237
|
+
// z-code reads Claude-compatible hooks but has neither the idle Notification nor StopFailure lifecycle event.
|
|
2238
|
+
// This is a real harness difference, not a TODO: the existing Claude-only idle state is unavailable.
|
|
2239
|
+
const ZCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
2028
2240
|
|
|
2029
2241
|
// the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
|
|
2030
2242
|
// launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
|
|
@@ -2035,6 +2247,7 @@ const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
|
|
|
2035
2247
|
const codexBaseCmd = (cmd?: string) => cmd || 'codex'
|
|
2036
2248
|
const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
|
|
2037
2249
|
const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
|
|
2250
|
+
const zcodeBaseCmd = (cmd?: string) => cmd || 'zcode'
|
|
2038
2251
|
|
|
2039
2252
|
// @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
|
|
2040
2253
|
// opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
|
|
@@ -2070,6 +2283,9 @@ const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDi
|
|
|
2070
2283
|
const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
|
|
2071
2284
|
(tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
|
|
2072
2285
|
|
|
2286
|
+
const panePidLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane) =>
|
|
2287
|
+
(tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline')
|
|
2288
|
+
|
|
2073
2289
|
const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
|
|
2074
2290
|
|
|
2075
2291
|
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
@@ -2119,6 +2335,7 @@ export const claudeHarness: Harness = {
|
|
|
2119
2335
|
events: CLAUDE_EVENTS,
|
|
2120
2336
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
2121
2337
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
2338
|
+
executionTrace: readProjectJsonlExecutionTrace,
|
|
2122
2339
|
launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
|
|
2123
2340
|
baseCmd: claudeBaseCmd,
|
|
2124
2341
|
sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
|
|
@@ -2140,7 +2357,7 @@ export const claudeHarness: Harness = {
|
|
|
2140
2357
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
2141
2358
|
liveness: socketListenerLiveness,
|
|
2142
2359
|
leafOwnerNeedle: (rec) => rec.session,
|
|
2143
|
-
deliver: (rec, text) =>
|
|
2360
|
+
deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
|
|
2144
2361
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2145
2362
|
coldRuntime: async () => ({ ok: true }),
|
|
2146
2363
|
// The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
|
|
@@ -2219,6 +2436,7 @@ export const codexHarness: Harness = {
|
|
|
2219
2436
|
events: CODEX_EVENTS,
|
|
2220
2437
|
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
2221
2438
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
2439
|
+
executionTrace: readCodexExecutionTrace,
|
|
2222
2440
|
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
|
|
2223
2441
|
baseCmd: codexBaseCmd,
|
|
2224
2442
|
sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
|
|
@@ -2282,6 +2500,7 @@ export const codexHarness: Harness = {
|
|
|
2282
2500
|
leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
|
|
2283
2501
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
2284
2502
|
observeTurnFailures: codexTurnFailureObserver,
|
|
2503
|
+
interrupt: interruptCodexTurn,
|
|
2285
2504
|
cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
2286
2505
|
targetDescriptorKey: (rec) => {
|
|
2287
2506
|
const endpoint = codexEndpointForRecord(rec)
|
|
@@ -2357,9 +2576,24 @@ export const codexHarness: Harness = {
|
|
|
2357
2576
|
if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
|
|
2358
2577
|
return { ok: true }
|
|
2359
2578
|
}
|
|
2579
|
+
const loadedSet = new Set(plan.guard.referenceIds)
|
|
2360
2580
|
for (const id of plan.activeIds) {
|
|
2361
|
-
|
|
2362
|
-
|
|
2581
|
+
// Only a loaded member pays the rollout flush, and an unreadable size must not become a small budget,
|
|
2582
|
+
// so it fails closed before the server is asked to mutate anything.
|
|
2583
|
+
let budgetMs = CODEX_MUTATION_BASE_MS
|
|
2584
|
+
if (loadedSet.has(id)) {
|
|
2585
|
+
const rollout = codexRolloutBytes(id)
|
|
2586
|
+
if ('unreadable' in rollout) return compensate(`Codex subtree member ${id} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown`)
|
|
2587
|
+
budgetMs = codexArchiveBudgetMs(rollout.bytes)
|
|
2588
|
+
}
|
|
2589
|
+
const archived = await codexThreadMutation(sock, 'thread/archive', id, fence, undefined, budgetMs)
|
|
2590
|
+
if (archived.ok) continue
|
|
2591
|
+
const reason = `${archived.error} while archiving Codex subtree member ${id}`
|
|
2592
|
+
// Compensating an unknown commit is what turns one slow member into a false "compensation failed": the
|
|
2593
|
+
// unarchive queues behind an archive the server is still executing and times out too. Report the unknown
|
|
2594
|
+
// commit instead — that is the recovery token resume already reconciles.
|
|
2595
|
+
if (archived.commit === 'unknown') return { ok: false, reason: `${reason}; commit state is unknown and no compensation was attempted` }
|
|
2596
|
+
return compensate(reason)
|
|
2363
2597
|
}
|
|
2364
2598
|
let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
|
|
2365
2599
|
const verifyDeadline = Date.now() + 30_000
|
|
@@ -2550,6 +2784,7 @@ export const piHarness: Harness = {
|
|
|
2550
2784
|
events: PI_EVENTS,
|
|
2551
2785
|
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
2552
2786
|
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
2787
|
+
executionTrace: readSessionJsonlExecutionTrace,
|
|
2553
2788
|
launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
|
|
2554
2789
|
baseCmd: piBaseCmd,
|
|
2555
2790
|
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
@@ -2600,6 +2835,44 @@ export const piHeadlessHarness: Harness = {
|
|
|
2600
2835
|
resumeArg: (rec) => `--session ${rec.session}`,
|
|
2601
2836
|
}
|
|
2602
2837
|
|
|
2838
|
+
const ZCODE_CONTROL_UNAVAILABLE = 'zcode has no control channel; start a new session instead of delivering to an existing one'
|
|
2839
|
+
|
|
2840
|
+
// z-code's app-server is stdin/stdout NDJSON, unlike Codex's Unix-socket WebSocket + thread RPC. This row
|
|
2841
|
+
// intentionally covers the one-turn `--prompt` launcher and Claude-compatible hooks only; control operations
|
|
2842
|
+
// refuse rather than pretending the incompatible protocol accepted them.
|
|
2843
|
+
export const zcodeHarness: Harness = {
|
|
2844
|
+
id: 'zcode',
|
|
2845
|
+
dispatchId: 'zcode',
|
|
2846
|
+
headless: true,
|
|
2847
|
+
launchOneShot: true,
|
|
2848
|
+
events: ZCODE_EVENTS,
|
|
2849
|
+
ownsRendezvous: false,
|
|
2850
|
+
paneTitleIsSelfSummary: false,
|
|
2851
|
+
executionTrace: noExecutionTrace,
|
|
2852
|
+
launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
|
|
2853
|
+
baseCmd: zcodeBaseCmd,
|
|
2854
|
+
sessionIdArg: () => '',
|
|
2855
|
+
sessionEnvVar: 'ZCODE_SESSION_ID',
|
|
2856
|
+
launchEnv: noLaunchEnv,
|
|
2857
|
+
shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
|
|
2858
|
+
shimScope: 'tree',
|
|
2859
|
+
worktreeHookAnchor: () => null,
|
|
2860
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')],
|
|
2861
|
+
skillDir: (proj) => join(proj, '.zcode', 'skills'),
|
|
2862
|
+
agentDir: (proj) => join(proj, '.zcode', 'agents'),
|
|
2863
|
+
shim: (dispatch, spex) => buildShim('zcode', ZCODE_EVENTS, dispatch, spex),
|
|
2864
|
+
writeTrust: () => [],
|
|
2865
|
+
removeTrust: () => { /* z-code wrote no trust artifact */ },
|
|
2866
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
2867
|
+
slashCommands: () => [],
|
|
2868
|
+
liveness: panePidLiveness,
|
|
2869
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
2870
|
+
deliver: async () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
|
|
2871
|
+
cleanupRuntime: async () => { /* one-shot z-code owns no SpexCode transport to remove */ },
|
|
2872
|
+
coldRuntime: async () => ({ ok: true }),
|
|
2873
|
+
resumeArg: () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2603
2876
|
export const opencodeHarness: Harness = {
|
|
2604
2877
|
id: 'opencode',
|
|
2605
2878
|
dispatchId: 'opencode',
|
|
@@ -2609,6 +2882,7 @@ export const opencodeHarness: Harness = {
|
|
|
2609
2882
|
// socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
|
|
2610
2883
|
ownsRendezvous: true,
|
|
2611
2884
|
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
2885
|
+
executionTrace: readLocalStoreExecutionTrace,
|
|
2612
2886
|
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
2613
2887
|
baseCmd: opencodeBaseCmd,
|
|
2614
2888
|
sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
|
|
@@ -2672,14 +2946,20 @@ export const opencodeHeadlessHarness: Harness = {
|
|
|
2672
2946
|
}
|
|
2673
2947
|
|
|
2674
2948
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
2675
|
-
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
|
|
2949
|
+
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
|
|
2676
2950
|
|
|
2677
2951
|
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
2678
2952
|
export const defaultHarness: Harness = claudeHarness
|
|
2679
2953
|
|
|
2954
|
+
// the registry lookup as DATA. A sweep over records nobody is currently asking about (a removed plugin, a
|
|
2955
|
+
// renamed id) must report an unresolvable harness rather than abort, so it resolves through this.
|
|
2956
|
+
export function harnessByIdOrNull(id: string): Harness | null {
|
|
2957
|
+
return HARNESSES.find((x) => x.id === id) ?? null
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2680
2960
|
// resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
|
|
2681
2961
|
export function harnessById(id: string): Harness {
|
|
2682
|
-
const h =
|
|
2962
|
+
const h = harnessByIdOrNull(id)
|
|
2683
2963
|
if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
|
|
2684
2964
|
return h
|
|
2685
2965
|
}
|