spexcode 0.6.2 → 0.6.5
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 +3 -1
- package/bin/spex.mjs +4 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +76 -0
- package/node_modules/@spexcode/spec-cli/dist/attach.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/attach.js +48 -0
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +33 -0
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +307 -0
- package/node_modules/@spexcode/spec-cli/dist/cli.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/cli.js +1859 -0
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +111 -0
- package/node_modules/@spexcode/spec-cli/dist/client.js +476 -0
- package/node_modules/@spexcode/spec-cli/dist/cockpit.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/cockpit.js +26 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-headless.d.ts +1 -0
- package/{spec-cli/src/codex-headless.ts → node_modules/@spexcode/spec-cli/dist/codex-headless.js} +3 -9
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +53 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +686 -0
- package/node_modules/@spexcode/spec-cli/dist/commit-surgery.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/commit-surgery.js +90 -0
- package/node_modules/@spexcode/spec-cli/dist/contract-filter.d.ts +14 -0
- package/node_modules/@spexcode/spec-cli/dist/contract-filter.js +219 -0
- package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.js +50 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-queue.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-queue.js +179 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +547 -0
- package/node_modules/@spexcode/spec-cli/dist/eval-host.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/eval-host.js +19 -0
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +26 -0
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +475 -0
- package/node_modules/@spexcode/spec-cli/dist/file-write.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/file-write.js +26 -0
- package/node_modules/@spexcode/spec-cli/dist/flat.d.ts +89 -0
- package/node_modules/@spexcode/spec-cli/dist/flat.js +1076 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-auth.d.ts +56 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-auth.js +168 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.d.ts +28 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +361 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +37 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +503 -0
- package/node_modules/@spexcode/spec-cli/dist/graphCache.d.ts +27 -0
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +716 -0
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.d.ts +14 -0
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +35 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +85 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.js +1195 -0
- package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.d.ts +64 -0
- package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.js +166 -0
- package/node_modules/@spexcode/spec-cli/dist/guide.d.ts +8 -0
- package/{spec-cli/src/guide.ts → node_modules/@spexcode/spec-cli/dist/guide.js} +36 -32
- package/node_modules/@spexcode/spec-cli/dist/harness-select.d.ts +18 -0
- package/node_modules/@spexcode/spec-cli/dist/harness-select.js +62 -0
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +325 -0
- package/node_modules/@spexcode/spec-cli/dist/harness.js +3026 -0
- package/node_modules/@spexcode/spec-cli/dist/headless-controller.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/headless-controller.js +47 -0
- package/node_modules/@spexcode/spec-cli/dist/help.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/help.js +574 -0
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.d.ts +18 -0
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +122 -0
- package/node_modules/@spexcode/spec-cli/dist/hooks.d.ts +1 -0
- package/{spec-cli/src/hooks.ts → node_modules/@spexcode/spec-cli/dist/hooks.js} +15 -15
- package/node_modules/@spexcode/spec-cli/dist/host-resources.d.ts +97 -0
- package/node_modules/@spexcode/spec-cli/dist/host-resources.js +795 -0
- package/node_modules/@spexcode/spec-cli/dist/host.d.ts +88 -0
- package/node_modules/@spexcode/spec-cli/dist/host.js +663 -0
- package/node_modules/@spexcode/spec-cli/dist/index.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/index.js +899 -0
- package/node_modules/@spexcode/spec-cli/dist/init.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/init.js +284 -0
- package/node_modules/@spexcode/spec-cli/dist/issues-cli.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/issues-cli.js +334 -0
- package/node_modules/@spexcode/spec-cli/dist/issues.d.ts +69 -0
- package/node_modules/@spexcode/spec-cli/dist/issues.js +189 -0
- package/node_modules/@spexcode/spec-cli/dist/lint.d.ts +34 -0
- package/node_modules/@spexcode/spec-cli/dist/lint.js +476 -0
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +7 -0
- package/{spec-cli/src/listen.ts → node_modules/@spexcode/spec-cli/dist/listen.js} +13 -17
- package/node_modules/@spexcode/spec-cli/dist/localIssues.d.ts +65 -0
- package/node_modules/@spexcode/spec-cli/dist/localIssues.js +582 -0
- package/node_modules/@spexcode/spec-cli/dist/login-page.d.ts +5 -0
- package/{spec-cli/src/login-page.ts → node_modules/@spexcode/spec-cli/dist/login-page.js} +7 -7
- package/node_modules/@spexcode/spec-cli/dist/loop-in.d.ts +23 -0
- package/{spec-cli/src/loop-in.ts → node_modules/@spexcode/spec-cli/dist/loop-in.js} +35 -45
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +69 -0
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +603 -0
- package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +18 -0
- package/node_modules/@spexcode/spec-cli/dist/materialize.js +468 -0
- package/node_modules/@spexcode/spec-cli/dist/mentions.d.ts +50 -0
- package/node_modules/@spexcode/spec-cli/dist/mentions.js +117 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +4 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +208 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode.d.ts +3 -0
- package/{spec-cli/src/opencode.ts → node_modules/@spexcode/spec-cli/dist/opencode.js} +9 -13
- package/node_modules/@spexcode/spec-cli/dist/pi-harness.d.ts +3 -0
- package/{spec-cli/src/pi-harness.ts → node_modules/@spexcode/spec-cli/dist/pi-harness.js} +45 -40
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +25 -0
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +196 -0
- package/node_modules/@spexcode/spec-cli/dist/plugin-harness.d.ts +21 -0
- package/node_modules/@spexcode/spec-cli/dist/plugin-harness.js +146 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-bridge.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +404 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-helper.d.mts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-helper.mjs +101 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.d.mts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.mjs +23 -0
- package/node_modules/@spexcode/spec-cli/dist/public-graph.d.ts +46 -0
- package/node_modules/@spexcode/spec-cli/dist/public-graph.js +56 -0
- package/node_modules/@spexcode/spec-cli/dist/ranker.d.ts +15 -0
- package/node_modules/@spexcode/spec-cli/dist/ranker.js +188 -0
- package/node_modules/@spexcode/spec-cli/dist/reaper.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/reaper.js +81 -0
- package/node_modules/@spexcode/spec-cli/dist/reviews.d.ts +189 -0
- package/node_modules/@spexcode/spec-cli/dist/reviews.js +350 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-guard.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-guard.js +37 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +21 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +84 -0
- package/node_modules/@spexcode/spec-cli/dist/search.bench.d.mts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/search.bench.mjs +75 -0
- package/node_modules/@spexcode/spec-cli/dist/search.d.ts +20 -0
- package/node_modules/@spexcode/spec-cli/dist/search.js +61 -0
- package/node_modules/@spexcode/spec-cli/dist/session-cursors.d.ts +14 -0
- package/node_modules/@spexcode/spec-cli/dist/session-cursors.js +82 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.d.ts +13 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +126 -0
- package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/session-execution.js +70 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +28 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.js +130 -0
- package/node_modules/@spexcode/spec-cli/dist/session-follow.d.ts +31 -0
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +179 -0
- package/node_modules/@spexcode/spec-cli/dist/session-reparent.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/session-reparent.js +33 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +51 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +233 -0
- package/node_modules/@spexcode/spec-cli/dist/session-web.d.ts +24 -0
- package/node_modules/@spexcode/spec-cli/dist/session-web.js +141 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +412 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +4239 -0
- package/node_modules/@spexcode/spec-cli/dist/sh.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/sh.js +3 -0
- package/node_modules/@spexcode/spec-cli/dist/shim-runtime.d.ts +1 -0
- package/{spec-cli/src/shim-runtime.ts → node_modules/@spexcode/spec-cli/dist/shim-runtime.js} +3 -4
- package/node_modules/@spexcode/spec-cli/dist/slash-commands.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/slash-commands.js +307 -0
- package/node_modules/@spexcode/spec-cli/dist/source-files.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/source-files.js +113 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +308 -0
- package/node_modules/@spexcode/spec-cli/dist/tree.d.ts +25 -0
- package/node_modules/@spexcode/spec-cli/dist/tree.js +98 -0
- package/node_modules/@spexcode/spec-cli/dist/tsx-bin.d.ts +3 -0
- package/node_modules/@spexcode/spec-cli/dist/tsx-bin.js +41 -0
- package/node_modules/@spexcode/spec-cli/dist/uninstall.d.ts +3 -0
- package/node_modules/@spexcode/spec-cli/dist/uninstall.js +177 -0
- package/node_modules/@spexcode/spec-cli/dist/uploads.d.ts +36 -0
- package/node_modules/@spexcode/spec-cli/dist/uploads.js +243 -0
- package/node_modules/@spexcode/spec-cli/dist/worktree-sources.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/worktree-sources.js +47 -0
- package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/dispatch.sh +5 -5
- package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/harness.sh +1 -1
- package/node_modules/@spexcode/spec-cli/package.json +47 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec-first.sh +1 -1
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +1 -0
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/spec.md +2 -2
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +31 -29
- package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +94 -0
- package/node_modules/@spexcode/spec-core/dist/anchors.js +730 -0
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +166 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +2736 -0
- package/node_modules/@spexcode/spec-core/dist/graph-delta.d.ts +44 -0
- package/node_modules/@spexcode/spec-core/dist/graph-delta.js +72 -0
- package/node_modules/@spexcode/spec-core/dist/graph.d.ts +34 -0
- package/node_modules/@spexcode/spec-core/dist/graph.js +237 -0
- package/node_modules/@spexcode/spec-core/dist/graphDelta.d.ts +3 -0
- package/node_modules/@spexcode/spec-core/dist/graphDelta.js +13 -0
- package/node_modules/@spexcode/spec-core/dist/harness-identity.d.ts +31 -0
- package/node_modules/@spexcode/spec-core/dist/harness-identity.js +20 -0
- package/node_modules/@spexcode/spec-core/dist/identity-presets.d.ts +152 -0
- package/node_modules/@spexcode/spec-core/dist/identity-presets.js +132 -0
- package/node_modules/@spexcode/spec-core/dist/index.d.ts +45 -0
- package/node_modules/@spexcode/spec-core/dist/index.js +19 -0
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +183 -0
- package/node_modules/@spexcode/spec-core/dist/layout.js +548 -0
- package/node_modules/@spexcode/spec-core/dist/process-identity.d.ts +37 -0
- package/node_modules/@spexcode/spec-core/dist/process-identity.js +214 -0
- package/node_modules/@spexcode/spec-core/dist/project-identity.d.ts +12 -0
- package/node_modules/@spexcode/spec-core/dist/project-identity.js +71 -0
- package/node_modules/@spexcode/spec-core/dist/project-store.d.ts +3 -0
- package/node_modules/@spexcode/spec-core/dist/project-store.js +14 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +2 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.js +40 -0
- package/node_modules/@spexcode/spec-core/dist/review/index.d.ts +3 -0
- package/node_modules/@spexcode/spec-core/dist/review/index.js +5 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +77 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +308 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +66 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +180 -0
- package/node_modules/@spexcode/spec-core/dist/review/session.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/review/session.js +8 -0
- package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.d.ts +15 -0
- package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.js +12 -0
- package/node_modules/@spexcode/spec-core/dist/root-lru.d.ts +4 -0
- package/{spec-cli/src/root-lru.ts → node_modules/@spexcode/spec-core/dist/root-lru.js} +26 -30
- package/node_modules/@spexcode/spec-core/dist/specs.d.ts +117 -0
- package/node_modules/@spexcode/spec-core/dist/specs.js +489 -0
- package/node_modules/@spexcode/spec-core/package.json +28 -0
- package/node_modules/@spexcode/spec-eval/dist/cache.d.ts +10 -0
- package/node_modules/@spexcode/spec-eval/dist/cache.js +50 -0
- package/node_modules/@spexcode/spec-eval/dist/cli.d.ts +22 -0
- package/node_modules/@spexcode/spec-eval/dist/cli.js +981 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +98 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.js +176 -0
- package/node_modules/@spexcode/spec-eval/dist/filing.d.ts +16 -0
- package/node_modules/@spexcode/spec-eval/dist/filing.js +36 -0
- package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +48 -0
- package/node_modules/@spexcode/spec-eval/dist/freshness.js +799 -0
- package/node_modules/@spexcode/spec-eval/dist/host.d.ts +46 -0
- package/node_modules/@spexcode/spec-eval/dist/host.js +53 -0
- package/node_modules/@spexcode/spec-eval/dist/humanok.d.ts +11 -0
- package/node_modules/@spexcode/spec-eval/dist/humanok.js +27 -0
- package/node_modules/@spexcode/spec-eval/dist/index.d.ts +4 -0
- package/node_modules/@spexcode/spec-eval/dist/index.js +4 -0
- package/node_modules/@spexcode/spec-eval/dist/remarks.d.ts +31 -0
- package/node_modules/@spexcode/spec-eval/dist/remarks.js +1 -0
- package/node_modules/@spexcode/spec-eval/dist/scenariofresh.d.ts +13 -0
- package/node_modules/@spexcode/spec-eval/dist/scenariofresh.js +303 -0
- package/node_modules/@spexcode/spec-eval/dist/scenarios.d.ts +99 -0
- package/node_modules/@spexcode/spec-eval/dist/scenarios.js +650 -0
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +307 -0
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +1883 -0
- package/node_modules/@spexcode/spec-eval/dist/sidecar.d.ts +55 -0
- package/node_modules/@spexcode/spec-eval/dist/sidecar.js +82 -0
- package/node_modules/@spexcode/spec-eval/dist/timeline.d.ts +25 -0
- package/node_modules/@spexcode/spec-eval/dist/timeline.js +65 -0
- package/node_modules/@spexcode/spec-eval/dist/ui-path.d.ts +1 -0
- package/node_modules/@spexcode/spec-eval/dist/ui-path.js +2 -0
- package/node_modules/@spexcode/spec-eval/package.json +36 -0
- package/node_modules/@spexcode/spec-forge/dist/cache.d.ts +29 -0
- package/node_modules/@spexcode/spec-forge/dist/cache.js +59 -0
- package/node_modules/@spexcode/spec-forge/dist/cli.d.ts +1 -0
- package/node_modules/@spexcode/spec-forge/dist/cli.js +100 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/github.d.ts +2 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/github.js +116 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.d.ts +7 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.js +144 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers.d.ts +10 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers.js +83 -0
- package/node_modules/@spexcode/spec-forge/dist/index.d.ts +4 -0
- package/node_modules/@spexcode/spec-forge/dist/index.js +4 -0
- package/node_modules/@spexcode/spec-forge/dist/links.d.ts +15 -0
- package/node_modules/@spexcode/spec-forge/dist/links.js +65 -0
- package/node_modules/@spexcode/spec-forge/dist/needs-eval.d.ts +10 -0
- package/node_modules/@spexcode/spec-forge/dist/needs-eval.js +20 -0
- package/node_modules/@spexcode/spec-forge/dist/port.d.ts +53 -0
- package/node_modules/@spexcode/spec-forge/dist/port.js +1 -0
- package/node_modules/@spexcode/spec-forge/dist/resident.d.ts +7 -0
- package/node_modules/@spexcode/spec-forge/dist/resident.js +43 -0
- package/node_modules/@spexcode/spec-forge/package.json +32 -0
- package/package.json +33 -19
- package/spec-cli/bin/spex.mjs +0 -69
- package/spec-cli/src/anchors.ts +0 -728
- package/spec-cli/src/attach.ts +0 -43
- package/spec-cli/src/claude-headless.ts +0 -321
- package/spec-cli/src/cli.ts +0 -1258
- package/spec-cli/src/client.ts +0 -414
- package/spec-cli/src/cockpit.ts +0 -43
- package/spec-cli/src/codex-runtime-generations.ts +0 -646
- package/spec-cli/src/commit-surgery.ts +0 -68
- package/spec-cli/src/contract-filter.ts +0 -202
- package/spec-cli/src/delivery-queue.ts +0 -165
- package/spec-cli/src/doctor.ts +0 -516
- package/spec-cli/src/execution-trace.ts +0 -444
- package/spec-cli/src/file-write.ts +0 -22
- package/spec-cli/src/gateway-auth.ts +0 -181
- package/spec-cli/src/gateway-hub.ts +0 -358
- package/spec-cli/src/gateway.ts +0 -461
- package/spec-cli/src/git.ts +0 -2556
- package/spec-cli/src/graph.ts +0 -271
- package/spec-cli/src/graphCache.ts +0 -710
- package/spec-cli/src/graphDelta.ts +0 -77
- package/spec-cli/src/graphStream.ts +0 -1019
- package/spec-cli/src/harness-select.ts +0 -77
- package/spec-cli/src/harness.ts +0 -3025
- package/spec-cli/src/headless-controller.ts +0 -63
- package/spec-cli/src/help.ts +0 -492
- package/spec-cli/src/host-resources.ts +0 -762
- package/spec-cli/src/host.ts +0 -611
- package/spec-cli/src/identity-presets.d.ts +0 -13
- package/spec-cli/src/identity-presets.js +0 -138
- package/spec-cli/src/index.ts +0 -818
- package/spec-cli/src/init.ts +0 -258
- package/spec-cli/src/issues-cli.ts +0 -276
- package/spec-cli/src/issues.ts +0 -237
- package/spec-cli/src/layout.ts +0 -640
- package/spec-cli/src/lint.ts +0 -488
- package/spec-cli/src/localIssues.ts +0 -538
- package/spec-cli/src/materialize.ts +0 -417
- package/spec-cli/src/mentions.ts +0 -81
- package/spec-cli/src/opencode-headless.ts +0 -205
- package/spec-cli/src/pi-headless.ts +0 -153
- package/spec-cli/src/plugin-harness.ts +0 -157
- package/spec-cli/src/process-identity.ts +0 -207
- package/spec-cli/src/project-identity.ts +0 -73
- package/spec-cli/src/project-store.ts +0 -17
- package/spec-cli/src/pty-bridge.ts +0 -396
- package/spec-cli/src/pty-helper.mjs +0 -96
- package/spec-cli/src/pty-native-helper.mjs +0 -22
- package/spec-cli/src/ranker.ts +0 -191
- package/spec-cli/src/reaper.ts +0 -125
- package/spec-cli/src/resilience.ts +0 -41
- package/spec-cli/src/reviewSnapshot.ts +0 -28
- package/spec-cli/src/reviews.ts +0 -434
- package/spec-cli/src/runtime-guard.ts +0 -35
- package/spec-cli/src/runtime-ownership.ts +0 -88
- package/spec-cli/src/search.bench.mjs +0 -80
- package/spec-cli/src/search.ts +0 -63
- package/spec-cli/src/session-cursors.ts +0 -91
- package/spec-cli/src/session-execution.ts +0 -68
- package/spec-cli/src/session-files.ts +0 -121
- package/spec-cli/src/session-follow.ts +0 -182
- package/spec-cli/src/session-reparent.ts +0 -33
- package/spec-cli/src/session-timeline.ts +0 -247
- package/spec-cli/src/session-web.ts +0 -135
- package/spec-cli/src/sessions.ts +0 -3883
- package/spec-cli/src/sh.ts +0 -3
- package/spec-cli/src/slash-commands.ts +0 -318
- package/spec-cli/src/source-files.ts +0 -104
- package/spec-cli/src/specs.ts +0 -498
- package/spec-cli/src/supervise.ts +0 -245
- package/spec-cli/src/tree.ts +0 -118
- package/spec-cli/src/tsx-bin.ts +0 -18
- package/spec-cli/src/uninstall.ts +0 -171
- package/spec-cli/src/uploads.ts +0 -253
- package/spec-cli/src/worktree-sources.ts +0 -42
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/App-B6IbFbGu.js +0 -2
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +0 -1
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-BG9rNOgV.js +0 -1
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +0 -2
- package/spec-dashboard/dist/assets/Modal-B3EcgeA3.js +0 -1
- package/spec-dashboard/dist/assets/PageScroll-CNZOugWV.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-C2CRHmvs.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +0 -32
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +0 -40
- package/spec-dashboard/dist/assets/SessionWindow-1nYgZ4CL.js +0 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +0 -1
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +0 -13
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +0 -1
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +0 -1
- package/spec-dashboard/dist/assets/index-DLVeOHL-.js +0 -41
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +0 -1
- package/spec-dashboard/dist/assets/launch-Cp579UoJ.css +0 -1
- package/spec-dashboard/dist/assets/launch-yxCYr64x.js +0 -276
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +0 -1
- package/spec-dashboard/dist/index.html +0 -29
- package/spec-dashboard/src/reviewFilters.js +0 -322
- package/spec-dashboard/src/reviewQuery.js +0 -174
- package/spec-dashboard/src/session.js +0 -199
- package/spec-eval/src/cache.ts +0 -57
- package/spec-eval/src/cli.ts +0 -894
- package/spec-eval/src/evaltab.ts +0 -264
- package/spec-eval/src/filing.ts +0 -39
- package/spec-eval/src/freshness.ts +0 -836
- package/spec-eval/src/humanok.ts +0 -28
- package/spec-eval/src/scenariofresh.ts +0 -244
- package/spec-eval/src/scenarios.ts +0 -672
- package/spec-eval/src/sessioneval.ts +0 -2220
- package/spec-eval/src/sidecar.ts +0 -93
- package/spec-eval/src/timeline.ts +0 -54
- package/spec-forge/src/__fixtures__/github-forge.json +0 -63
- package/spec-forge/src/cache.ts +0 -66
- package/spec-forge/src/cli.ts +0 -99
- package/spec-forge/src/drivers/github.ts +0 -132
- package/spec-forge/src/drivers/gitlab.ts +0 -153
- package/spec-forge/src/drivers.ts +0 -73
- package/spec-forge/src/links.ts +0 -72
- package/spec-forge/src/needs-eval.ts +0 -29
- package/spec-forge/src/port.ts +0 -36
- package/spec-forge/src/resident.ts +0 -46
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/README.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-sed-v0.fixture +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/commit-msg +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-checkout +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-merge +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/pre-commit +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/prepare-commit-msg +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/reference-transaction +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/extract/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/regroup/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/rename/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/tidy/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/comment-altitude/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/idle.sh +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/mark-active.sh +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/fail.sh +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/forge-link/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/digest.mjs +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-core}/templates/spexcode.json +0 -0
|
@@ -0,0 +1,3026 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename } from 'node:path';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { createConnection } from 'node:net';
|
|
6
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands } from './slash-commands.js';
|
|
10
|
+
import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js';
|
|
11
|
+
import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js';
|
|
12
|
+
import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
|
|
13
|
+
import { codexHeadlessLaunchCommand } from './codex-headless.js';
|
|
14
|
+
import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
|
|
15
|
+
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
|
|
16
|
+
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from '@spexcode/spec-core';
|
|
17
|
+
import { git } from '@spexcode/spec-core';
|
|
18
|
+
import { shQuote } from './sh.js';
|
|
19
|
+
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
|
|
20
|
+
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
|
|
21
|
+
import { writeFileIfChanged } from './file-write.js';
|
|
22
|
+
import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace } from './execution-trace.js';
|
|
23
|
+
import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
|
|
24
|
+
// One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
|
|
25
|
+
// once per call, then its result is joined to every record that names that adapter/thread. Product readers must
|
|
26
|
+
// not turn this into one RPC per row: a loaded thread can be externally reloaded after its cold proof was filed.
|
|
27
|
+
export async function adapterLoadedReferenceState(records, runtimeDir = runtimeRoot()) {
|
|
28
|
+
const descriptors = new Map();
|
|
29
|
+
const recordKeys = new Map();
|
|
30
|
+
for (const rec of records) {
|
|
31
|
+
if (!rec.harnessSessionId)
|
|
32
|
+
continue;
|
|
33
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
34
|
+
const exactKey = harness.targetDescriptorKey?.(rec) ?? null;
|
|
35
|
+
const keys = (harness.sharedRuntimes?.(runtimeDir) ?? []).filter((descriptor) => !exactKey || descriptor.key === exactKey).map((descriptor) => {
|
|
36
|
+
descriptors.set(descriptor.key, descriptor);
|
|
37
|
+
return descriptor.key;
|
|
38
|
+
});
|
|
39
|
+
recordKeys.set(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`, keys);
|
|
40
|
+
}
|
|
41
|
+
const probes = await Promise.all([...descriptors.entries()].map(async ([key, descriptor]) => {
|
|
42
|
+
try {
|
|
43
|
+
const result = descriptor.residency
|
|
44
|
+
? await descriptor.residency()
|
|
45
|
+
: await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }));
|
|
46
|
+
return [key, result];
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return [key, { healthy: false, referenceIds: [], error: error.message }];
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
const byKey = new Map(probes);
|
|
53
|
+
const result = new Map();
|
|
54
|
+
for (const [recordKey, keys] of recordKeys) {
|
|
55
|
+
const refs = keys.map((key) => byKey.get(key)).filter(Boolean);
|
|
56
|
+
if (!refs.length)
|
|
57
|
+
continue;
|
|
58
|
+
const unhealthy = refs.find((probe) => !probe.healthy);
|
|
59
|
+
const threadId = recordKey.slice(recordKey.indexOf(':') + 1);
|
|
60
|
+
result.set(recordKey, unhealthy
|
|
61
|
+
? { healthy: false, loaded: false, error: unhealthy.error || 'adapter resident-reference census is unhealthy' }
|
|
62
|
+
: { healthy: true, loaded: refs.some((probe) => probe.referenceIds.includes(threadId)) });
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
// @@@ rendezvous control socket - claude's DETERMINISTIC, ONLY input path for PROMPTS to sessions WE launch.
|
|
67
|
+
// sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
|
|
68
|
+
// that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
|
|
69
|
+
// `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
|
|
70
|
+
// and Enters can't be corrupted the way `tmux send-keys` was. It lives in tmpdir tied to the claude process, so
|
|
71
|
+
// no extra lifecycle. liveness CONNECTS to it (a live LISTENER, not merely the file — see rendezvousListening);
|
|
72
|
+
// deliver writes to it.
|
|
73
|
+
//
|
|
74
|
+
// The path is a LAUNCH-TIME FACT, recorded — not a formula every consumer re-derives. The id alone was not
|
|
75
|
+
// enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
|
|
76
|
+
// worlds on one box (a fixture, a migration, a copied record) can hold the same session id — and while the
|
|
77
|
+
// path ignored that scoping, they SHARED this socket. That is how an isolated teardown reached out and
|
|
78
|
+
// stranded a live production agent, and delivery would have crossed the same way. So the path a launch hands
|
|
79
|
+
// its agent is derived from the runtime the session belongs to (`runtimeRoot()` — the same identity that
|
|
80
|
+
// scopes its store) and STAMPED beside the record, exactly like `agent.pid`: a launch-time fact, readable by
|
|
81
|
+
// everyone who needs to reach that agent afterwards. Recording it (rather than re-deriving) also means the
|
|
82
|
+
// derivation can change again without stranding anything already running.
|
|
83
|
+
// `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
|
|
84
|
+
// the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
|
|
85
|
+
export const legacyRvSock = (id) => join(tmpdir(), `spexcode-rv-${id}.sock`);
|
|
86
|
+
// @@@ rendezvousSocketBase - macOS's TMPDIR commonly expands to /var/folders/<long-user-path>, leaving a
|
|
87
|
+
// UUID-scoped socket beyond its 104-byte sun_path limit. It can be created and observed by pathname but every
|
|
88
|
+
// connect fails EINVAL, which looks exactly like an unresponsive worker. Use a literal short /tmp spelling
|
|
89
|
+
// (not tmpdir(), whose resolved form is long on macOS) with the same private per-uid directory pattern as the
|
|
90
|
+
// Codex runtime socket. Existing launches retain their stamped old path through rvSock(); only new launches use
|
|
91
|
+
// this base.
|
|
92
|
+
const rendezvousSocketBase = () => {
|
|
93
|
+
const base = join('/tmp', `spexcode-rv-${process.getuid?.() ?? 0}`);
|
|
94
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
95
|
+
return base;
|
|
96
|
+
};
|
|
97
|
+
export const scopedRvSock = (id, dir = runtimeRoot()) => join(rendezvousSocketBase(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`);
|
|
98
|
+
const rvStamp = (id) => sessionArtifactPath(id, 'rv.path');
|
|
99
|
+
export const rvSock = (id) => {
|
|
100
|
+
try {
|
|
101
|
+
return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return legacyRvSock(id);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
|
|
108
|
+
// liveness probe, delivery, teardown) reads the one path the agent actually bound.
|
|
109
|
+
export function stampRvSock(id, dir = runtimeRoot()) {
|
|
110
|
+
const path = scopedRvSock(id, dir);
|
|
111
|
+
mkdirSync(dirname(rvStamp(id)), { recursive: true });
|
|
112
|
+
writeFileSync(rvStamp(id), path);
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT']);
|
|
116
|
+
export function listenerAt(path, timeoutMs = 800) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
let settled = false;
|
|
119
|
+
let c;
|
|
120
|
+
const done = (v) => {
|
|
121
|
+
if (settled)
|
|
122
|
+
return;
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
try {
|
|
126
|
+
c?.destroy();
|
|
127
|
+
}
|
|
128
|
+
catch { /* */ }
|
|
129
|
+
resolve(v);
|
|
130
|
+
};
|
|
131
|
+
const timer = setTimeout(() => done('unproven'), timeoutMs);
|
|
132
|
+
try {
|
|
133
|
+
c = createConnection({ path });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return done('unproven');
|
|
137
|
+
}
|
|
138
|
+
c.on('connect', () => done('live'));
|
|
139
|
+
c.on('error', (e) => done(PROVEN_DEAD.has(e.code ?? '') ? 'dead' : 'unproven'));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export const rendezvousListening = (id, timeoutMs = 800) => listenerAt(rvSock(id), timeoutMs);
|
|
143
|
+
// The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
|
|
144
|
+
// dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
|
|
145
|
+
// dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
|
|
146
|
+
// project (~111 chars) → `path must be shorter than SUN_LEN` + connect EINVAL, and the app-server never bound
|
|
147
|
+
// (Linux's 108 limit + shorter `/root` paths happened to fit; macOS did not). So the socket is
|
|
148
|
+
// `<socketBase>/spexcode-cx-<hash>.sock`, where `<hash>` is a short STABLE digest of the PROJECT identity — the
|
|
149
|
+
// `dir` (runtimeDir) the callers pass — so launch, liveness, and delivery all compute the IDENTICAL sock for a
|
|
150
|
+
// given project (the ONE-app-server-per-project invariant). This is UNCONDITIONAL on every platform (a short
|
|
151
|
+
// hashed path is strictly better everywhere — no darwin branch; platform differences stay at this path seam).
|
|
152
|
+
// `<socketBase>` = the `SPEXCODE_CODEX_SOCKET_DIR` override, else an OWNED per-uid subdir of the platform
|
|
153
|
+
// tmpdir (`spexcode-cx-<uid>`, created 0700) — NEVER bare tmpdir: codex (0.137+ field-confirmed) refuses to
|
|
154
|
+
// bind a unix socket directly in the shared sticky `/tmp` on a host with `fs.protected_regular=2` (EPERM), so
|
|
155
|
+
// the bare-tmpdir default failed every codex launch on a stock hardened Ubuntu out of the box (github#30),
|
|
156
|
+
// while the SAME codex binds fine in any owned subdirectory. Per-uid (not one shared `spexcode-cx`) so a
|
|
157
|
+
// second user on the box never lands in the first user's 0700 dir. The derivation GUARANTEES the dir exists
|
|
158
|
+
// (idempotent mkdir) so every consumer — launch bake, liveness connect, delivery, tests — shares one creation
|
|
159
|
+
// point. The `.pid`/`.log`/`.lock` files carry no sun_path limit and stay in `runtimeRoot`.
|
|
160
|
+
export const codexAppServerSock = (dir = runtimeRoot()) => {
|
|
161
|
+
return codexGenerationSocketPath(dir);
|
|
162
|
+
};
|
|
163
|
+
export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid');
|
|
164
|
+
export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json');
|
|
165
|
+
const codexAppServerLegacyScope = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope');
|
|
166
|
+
function codexRuntimeGenerationProof(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
167
|
+
try {
|
|
168
|
+
const pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim());
|
|
169
|
+
const detached = verifyDetachedRuntime(pid, endpoint.receiptFile);
|
|
170
|
+
const socketPath = endpoint.socketPath;
|
|
171
|
+
const socket = statSync(socketPath);
|
|
172
|
+
if (!(pid > 0) || !detached.ok || !socket.isSocket())
|
|
173
|
+
return null;
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
identity: detached.identity,
|
|
176
|
+
socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const codexRuntimeGenerationToken = (proof) => `${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`;
|
|
184
|
+
function codexRuntimeGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
185
|
+
const proof = codexRuntimeGenerationProof(dir, endpoint);
|
|
186
|
+
return proof ? codexRuntimeGenerationToken(proof) : null;
|
|
187
|
+
}
|
|
188
|
+
function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
189
|
+
const current = codexRuntimeGeneration(dir, endpoint);
|
|
190
|
+
if (current)
|
|
191
|
+
return current;
|
|
192
|
+
if (endpoint.id !== 'legacy')
|
|
193
|
+
return null;
|
|
194
|
+
let pid;
|
|
195
|
+
try {
|
|
196
|
+
pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim());
|
|
197
|
+
if (!Number.isInteger(pid) || pid <= 0 || !statSync(codexAppServerSock(dir)).isSocket())
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (!migrateLegacyDetachedRuntimeReceipt(pid, codexAppServerLegacyScope(dir), codexAppServerReceipt(dir)))
|
|
204
|
+
return null;
|
|
205
|
+
return codexRuntimeGeneration(dir, endpoint);
|
|
206
|
+
}
|
|
207
|
+
const codexDescriptorKey = (endpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`;
|
|
208
|
+
function codexEndpointForRecord(rec, dir = runtimeRoot()) {
|
|
209
|
+
if (!rec.harnessSessionId)
|
|
210
|
+
return null;
|
|
211
|
+
const ledger = readCodexGenerationLedger(dir);
|
|
212
|
+
if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length)
|
|
213
|
+
return legacyCodexGenerationEndpoint(dir);
|
|
214
|
+
return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId);
|
|
215
|
+
}
|
|
216
|
+
// the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
|
|
217
|
+
// the launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
|
|
218
|
+
// exec's the visible TUI. The launcher, never a raw source entry: it runs the compiled CLI and keeps the
|
|
219
|
+
// source-workspace mid-merge guard (conflicted source -> one line + exit 75, not a stacktrace).
|
|
220
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url));
|
|
221
|
+
const SPEX = join(PKG, 'bin', 'spex.mjs');
|
|
222
|
+
// The backend need not share the agent's config root: an explicitly chosen launcher can point Claude at its
|
|
223
|
+
// own home. A moved source process remains alive by definition, so its one config-dir environment field is the
|
|
224
|
+
// live authority for locating the daemon roster. A stale/reused pid can at worst name a roster with no exact
|
|
225
|
+
// successor; it cannot select one without the moved/session-source checks below.
|
|
226
|
+
function claudeConfigRoots(sourceSessionId, runtimeDir) {
|
|
227
|
+
const roots = [];
|
|
228
|
+
try {
|
|
229
|
+
const pidFile = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'agent.pid') : sessionArtifactPath(sourceSessionId, 'agent.pid');
|
|
230
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
231
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
232
|
+
const env = readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0');
|
|
233
|
+
const config = env.find((entry) => entry.startsWith('CLAUDE_CONFIG_DIR='))?.slice('CLAUDE_CONFIG_DIR='.length);
|
|
234
|
+
if (config)
|
|
235
|
+
roots.push(config);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch { /* source not available or this platform does not expose procfs */ }
|
|
239
|
+
if (process.env.CLAUDE_CONFIG_DIR)
|
|
240
|
+
roots.push(process.env.CLAUDE_CONFIG_DIR);
|
|
241
|
+
roots.push(join(homedir(), '.claude'));
|
|
242
|
+
return [...new Set(roots)];
|
|
243
|
+
}
|
|
244
|
+
// A moved Claude conversation is a daemon-owned fork. Its launch-time socket still answers, but that process
|
|
245
|
+
// no longer returns to its prompt. A successor hook may have already recorded its exact Claude session id in
|
|
246
|
+
// `moved`; use that durable identity first, then retain the roster's source-transcript relation for deployments
|
|
247
|
+
// without the stamp. The roster remains the sole source of the live socket and current auth token. Keep this
|
|
248
|
+
// lookup Claude-local so other rendezvous adapters cannot adopt a coincidentally matching id.
|
|
249
|
+
function claudeForkTransport(sourceSessionId, runtimeDir) {
|
|
250
|
+
const moved = (() => {
|
|
251
|
+
try {
|
|
252
|
+
const stamp = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'moved') : sessionArtifactPath(sourceSessionId, 'moved');
|
|
253
|
+
return readFileSync(stamp, 'utf8').trim();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return '';
|
|
257
|
+
}
|
|
258
|
+
})();
|
|
259
|
+
for (const configDir of claudeConfigRoots(sourceSessionId, runtimeDir)) {
|
|
260
|
+
try {
|
|
261
|
+
const roster = JSON.parse(readFileSync(join(configDir, 'daemon', 'roster.json'), 'utf8'));
|
|
262
|
+
const workers = Object.values(roster.workers ?? {});
|
|
263
|
+
const usable = (worker) => typeof worker?.rendezvousSock === 'string' && typeof worker.rvAuth === 'string';
|
|
264
|
+
const recorded = workers.filter((worker) => usable(worker) && moved && worker?.sessionId === moved);
|
|
265
|
+
const candidates = recorded.length ? recorded : workers.filter((worker) => {
|
|
266
|
+
const launch = worker?.dispatch?.launch;
|
|
267
|
+
if (launch?.mode !== 'resume' || launch.fork !== true || typeof launch.sessionId !== 'string')
|
|
268
|
+
return false;
|
|
269
|
+
const source = basename(launch.sessionId).replace(/\.jsonl$/, '');
|
|
270
|
+
return source === sourceSessionId && usable(worker);
|
|
271
|
+
});
|
|
272
|
+
const worker = candidates.sort((a, b) => Number(b.startedAt ?? 0) - Number(a.startedAt ?? 0))[0];
|
|
273
|
+
if (worker)
|
|
274
|
+
return { sock: worker.rendezvousSock, auth: worker.rvAuth };
|
|
275
|
+
}
|
|
276
|
+
catch { /* this Claude config has no readable daemon roster */ }
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
function replyViaSocket(sock, text, mid, auth) {
|
|
281
|
+
return new Promise((resolve) => {
|
|
282
|
+
let settled = false;
|
|
283
|
+
let c;
|
|
284
|
+
const done = (r) => {
|
|
285
|
+
if (settled)
|
|
286
|
+
return;
|
|
287
|
+
settled = true;
|
|
288
|
+
if (!r.ok)
|
|
289
|
+
try {
|
|
290
|
+
c?.destroy();
|
|
291
|
+
}
|
|
292
|
+
catch { /* */ }
|
|
293
|
+
resolve(r);
|
|
294
|
+
};
|
|
295
|
+
try {
|
|
296
|
+
c = createConnection({ path: sock });
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
c.on('error', (e) => {
|
|
303
|
+
const code = e?.code || String(e);
|
|
304
|
+
done({ ok: false, error: `rendezvous socket error: ${code}` });
|
|
305
|
+
});
|
|
306
|
+
c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }));
|
|
307
|
+
c.on('connect', () => c.write(`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`, (error) => {
|
|
308
|
+
if (error)
|
|
309
|
+
return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` });
|
|
310
|
+
c.end();
|
|
311
|
+
done({ ok: true });
|
|
312
|
+
}));
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const POKE_ATTEMPTS = 2;
|
|
316
|
+
async function pokeRendezvous(sock, text, mid, auth) {
|
|
317
|
+
let last = { ok: false, error: 'not attempted' };
|
|
318
|
+
for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
|
|
319
|
+
last = await replyViaSocket(sock, text, mid, auth);
|
|
320
|
+
if (last.ok)
|
|
321
|
+
return last;
|
|
322
|
+
}
|
|
323
|
+
return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` };
|
|
324
|
+
}
|
|
325
|
+
export async function deliverViaRendezvous(id, text, mid) {
|
|
326
|
+
return pokeRendezvous(rvSock(id), text, mid);
|
|
327
|
+
}
|
|
328
|
+
export async function deliverViaClaudeRendezvous(id, text, mid, runtimeDir) {
|
|
329
|
+
const fork = claudeForkTransport(id, runtimeDir);
|
|
330
|
+
const sourceSock = rvSock(id);
|
|
331
|
+
if (!fork)
|
|
332
|
+
return pokeRendezvous(sourceSock, text, mid);
|
|
333
|
+
const forkResult = await pokeRendezvous(fork.sock, text, mid, fork.auth);
|
|
334
|
+
if (forkResult.ok || fork.sock === sourceSock)
|
|
335
|
+
return forkResult;
|
|
336
|
+
const sourceResult = await pokeRendezvous(sourceSock, text, mid);
|
|
337
|
+
if (sourceResult.ok)
|
|
338
|
+
return sourceResult;
|
|
339
|
+
return { ok: false, error: `fork rendezvous failed: ${forkResult.error}; source fallback failed: ${sourceResult.error}` };
|
|
340
|
+
}
|
|
341
|
+
export async function deliverViaSocketOrWake(id, text, mid, coldWake, unprovenError) {
|
|
342
|
+
const probe = await rendezvousListening(id);
|
|
343
|
+
if (probe === 'live')
|
|
344
|
+
return deliverViaRendezvous(id, text, mid);
|
|
345
|
+
if (probe === 'unproven')
|
|
346
|
+
return { ok: false, error: unprovenError };
|
|
347
|
+
return coldWake();
|
|
348
|
+
}
|
|
349
|
+
// The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
|
|
350
|
+
// 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
|
|
351
|
+
// `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
|
|
352
|
+
// it (that re-loads a thread the live TUI already owns). Instead `thread/loaded/list` PROVES the captured thread
|
|
353
|
+
// is the one the pane is showing, then `thread/read{includeTurns}` reveals whether a turn is in progress (and
|
|
354
|
+
// its id). The 4th, injecting message is CHOSEN from that read — see codexInjectMessage.
|
|
355
|
+
const codexTextInput = (text) => [{ type: 'text', text, text_elements: [] }];
|
|
356
|
+
export function codexHandshakeMessages(threadId) {
|
|
357
|
+
return [
|
|
358
|
+
{
|
|
359
|
+
id: 1,
|
|
360
|
+
method: 'initialize',
|
|
361
|
+
params: {
|
|
362
|
+
clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' },
|
|
363
|
+
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{ method: 'initialized', params: {} },
|
|
367
|
+
{ id: 2, method: 'thread/loaded/list', params: {} },
|
|
368
|
+
{ id: 3, method: 'thread/read', params: { threadId, includeTurns: true } },
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
// the message that injects `text`. STEER (turn/steer) when an active turn id is known — codex processes it
|
|
372
|
+
// WITHOUT waiting for the current turn to end (the human's "工具调用完就插入": injected the moment the running
|
|
373
|
+
// tool call returns), so a busy agent reacts mid-turn instead of queuing the message for after it stops.
|
|
374
|
+
// `TurnSteerParams` REQUIRES the live turn id as `expectedTurnId` (the server rejects a stale one) — so this is
|
|
375
|
+
// only sent with a turnId read live from the thread, never from SpexCode's session status. When the thread is
|
|
376
|
+
// idle (no active turn id), START a fresh turn (turn/start). `id` is parameterized so a steer that loses the
|
|
377
|
+
// expectedTurnId race (turn ended in the read→steer window) can retry as a turn/start with id 5.
|
|
378
|
+
export function codexInjectMessage(threadId, text, cwd, activeTurnId, id = 4, clientUserMessageId) {
|
|
379
|
+
const marker = clientUserMessageId ? { clientUserMessageId } : {};
|
|
380
|
+
if (activeTurnId)
|
|
381
|
+
return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId, ...marker } };
|
|
382
|
+
return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}), ...marker } };
|
|
383
|
+
}
|
|
384
|
+
// the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. With
|
|
385
|
+
// includeTurns the Thread carries its turns, each with a TurnStatus ("completed"|"interrupted"|"failed"|
|
|
386
|
+
// "inProgress"); the live turn is the `inProgress` one and its id is exactly what turn/steer's precondition needs.
|
|
387
|
+
export function activeTurnIdFromThread(readResult) {
|
|
388
|
+
const thread = readResult?.thread;
|
|
389
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : [];
|
|
390
|
+
const active = turns.find((t) => t?.status === 'inProgress');
|
|
391
|
+
return active?.id ?? null;
|
|
392
|
+
}
|
|
393
|
+
// The app-server and the visible `--remote … resume` TUI share ONE socket, so they MUST be the SAME codex
|
|
394
|
+
// install — a version split across that socket breaks the thread/start→resume handoff (an app-server on one
|
|
395
|
+
// version creates a thread a differently-versioned resume can't find; an old-enough app-server can't serve
|
|
396
|
+
// `--remote unix://` at all). So `serverCmd` is DERIVED from the in-effect `codexCmd`'s binary (its first shell
|
|
397
|
+
// token, dropping args like `--yolo`) whenever it isn't explicitly forced: `<bin> app-server` then runs the
|
|
398
|
+
// SAME install as `<bin> --remote … resume`. Bare `codex` is NOT the default anymore — on a multi-install host
|
|
399
|
+
// (e.g. homebrew codex shadowing an nvm codex) a bare `codex` resolves via the login-shell PATH to a DIFFERENT
|
|
400
|
+
// binary than the launcher's, which is exactly the version-skew bug. `SPEXCODE_CODEX_SERVER_CMD` stays the
|
|
401
|
+
// explicit escape hatch (highest precedence). Caveat: if `codexCmd`'s first token is a WRAPPER script rather
|
|
402
|
+
// than codex itself, the derived `<wrapper> app-server` only works if the wrapper forwards to codex — the
|
|
403
|
+
// common direct-binary case (`codex …`, `/abs/codex --yolo`) is what this fixes.
|
|
404
|
+
export function codexBinary(codexCmd) {
|
|
405
|
+
return codexCmd.trim().split(/\s+/)[0] || 'codex';
|
|
406
|
+
}
|
|
407
|
+
const regexEscape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
408
|
+
const codexOptionValue = (command, flags, values) => {
|
|
409
|
+
const flagPattern = flags.map(regexEscape).join('|');
|
|
410
|
+
const valuePattern = values.map(regexEscape).join('|');
|
|
411
|
+
const matcher = new RegExp(`(?:^|\\s)(?:${flagPattern})(?:=|\\s+)[\"']?(${valuePattern})[\"']?(?=$|\\s)`, 'g');
|
|
412
|
+
let found;
|
|
413
|
+
for (const match of command.matchAll(matcher))
|
|
414
|
+
found = match[1];
|
|
415
|
+
return found;
|
|
416
|
+
};
|
|
417
|
+
// The backend creates the thread before the visible remote TUI, so translate launcher autonomy flags at this
|
|
418
|
+
// adapter boundary. A flag on the later resume command cannot change an already-created thread.
|
|
419
|
+
export function codexLauncherThreadPolicy(command) {
|
|
420
|
+
if (/(?:^|\s)(?:--yolo|--dangerously-bypass-approvals-and-sandbox)(?=$|\s)/.test(command)) {
|
|
421
|
+
return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
|
|
422
|
+
}
|
|
423
|
+
const approvalPolicy = codexOptionValue(command, ['--ask-for-approval', '-a'], ['untrusted', 'on-request', 'never']);
|
|
424
|
+
const sandbox = codexOptionValue(command, ['--sandbox', '-s'], ['read-only', 'workspace-write', 'danger-full-access']);
|
|
425
|
+
return { ...(approvalPolicy ? { approvalPolicy } : {}), ...(sandbox ? { sandbox } : {}) };
|
|
426
|
+
}
|
|
427
|
+
// codex >=0.142 adds `--dangerously-bypass-hook-trust` — run our OWN (vetted) dispatch hooks without a persisted
|
|
428
|
+
// trusted_hash. We PREFER it over reverse-engineering codexHookHash: that hash is pinned to one codex version's
|
|
429
|
+
// format and silently breaks on a bump (codex then skips ALL our hooks -> no Stop gate, no mark-active, sessions
|
|
430
|
+
// die undeclared). The flag is version-robust. But an OLDER codex HARD-ERRORS on the unknown flag (the whole
|
|
431
|
+
// app-server fails to boot), so we CAPABILITY-PROBE the binary once (`--help` grep) and only pass it when
|
|
432
|
+
// present; otherwise the writeCodexTrust hash path still stands in. Memoized — a per-binary constant.
|
|
433
|
+
const bypassProbe = new Map();
|
|
434
|
+
export function codexSupportsBypassHookTrust(binary) {
|
|
435
|
+
// explicit escape hatch (also what makes this deterministic in tests): force the capability on/off regardless
|
|
436
|
+
// of the binary — e.g. if the `--help` probe is unreliable on a wrapper, or to pin behaviour.
|
|
437
|
+
const env = process.env.SPEXCODE_CODEX_BYPASS_HOOK_TRUST;
|
|
438
|
+
if (env !== undefined)
|
|
439
|
+
return env === '1' || env === 'true';
|
|
440
|
+
const hit = bypassProbe.get(binary);
|
|
441
|
+
if (hit !== undefined)
|
|
442
|
+
return hit;
|
|
443
|
+
let ok = false;
|
|
444
|
+
try {
|
|
445
|
+
ok = execFileSync(binary, ['--help'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).includes('--dangerously-bypass-hook-trust');
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
ok = false;
|
|
449
|
+
}
|
|
450
|
+
bypassProbe.set(binary, ok);
|
|
451
|
+
return ok;
|
|
452
|
+
}
|
|
453
|
+
// Headless adapters all feed non-zero ephemeral turn exits through one state writer. Keep this reporter at the
|
|
454
|
+
// adapter seam: controllers may call it directly, while shell-homed turns use headlessTurnFailureShell below.
|
|
455
|
+
export async function reportHeadlessTurnExit(id, harness, code, cwd = process.cwd()) {
|
|
456
|
+
if (code === 0)
|
|
457
|
+
return;
|
|
458
|
+
const exitCode = code === null ? 'signal' : String(code);
|
|
459
|
+
try {
|
|
460
|
+
await pexec(SPEX, ['internal', 'session-turn-fail', id, harness, exitCode], { cwd, env: process.env });
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
console.error(`[spex ${harness}] could not record turn failure for ${id}: ${error.message}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
export function headlessTurnFailureShell(harness, swallow = true) {
|
|
467
|
+
return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc"${swallow ? ' || true' : ''}`;
|
|
468
|
+
}
|
|
469
|
+
// @@@ sessionIdentityEnvVars - every environment variable that names ONE session: the launch-injected record
|
|
470
|
+
// id plus each adapter's own `sessionEnvVar`. Adapter-derived, so a new harness needs no edit here. A
|
|
471
|
+
// per-session process is entitled to carry them; a SHARED, project-scoped daemon must not — see the app-server
|
|
472
|
+
// spawn below.
|
|
473
|
+
export function sessionIdentityEnvVars() {
|
|
474
|
+
return [...new Set(['SPEXCODE_SESSION_ID', ...HARNESS_IDENTITIES.map((h) => h.sessionEnvVar)])].filter(Boolean);
|
|
475
|
+
}
|
|
476
|
+
export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runtimeRoot(), attachTui = true) {
|
|
477
|
+
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd);
|
|
478
|
+
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
479
|
+
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
480
|
+
// thread — it was INERT there, the bug). Two thread paths carry it: (1) the BACKEND-owned `thread/start` sends
|
|
481
|
+
// `config.bypass_hook_trust` from codex-launch ([[harness-adapter]]); (2) the visible `--remote … resume` TUI,
|
|
482
|
+
// where codex's OWN client forwards this flag into its thread/start+thread/resume config — so a reopen in a
|
|
483
|
+
// fresh app-server (where codex-launch never runs) still trusts our hooks. Hence the flag lives on the resume
|
|
484
|
+
// TUI, never on the app-server invocation. Guarded against a double-flag when an env override already carries it.
|
|
485
|
+
const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : '';
|
|
486
|
+
const script = [
|
|
487
|
+
`dir=${shQuote(dir)}`,
|
|
488
|
+
// codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
|
|
489
|
+
// WE already hold the launcher's real cmd here (it drives the app-server + resume TUI + tuiBypass above), so
|
|
490
|
+
// pin it into the environment the codex-launch child inherits. Without this the child falls back to a bare
|
|
491
|
+
// `codex`, which on a multi-install box (e.g. an old Homebrew codex on PATH beside the launcher's newer one)
|
|
492
|
+
// probes the WRONG binary — deciding "no --dangerously-bypass-hook-trust support" and silently dropping the
|
|
493
|
+
// thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
|
|
494
|
+
`export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
|
|
495
|
+
// The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
|
|
496
|
+
// resume resolves its existing session/thread binding, so a LIVE root never has its conversation moved to a
|
|
497
|
+
// replacement. Both spellings carry the server command because either may be the launch that has to start a
|
|
498
|
+
// root: after a host restart the bound generation is a corpse, and resume rebuilds one to load the same
|
|
499
|
+
// on-disk rollout. It prints only shell assignments for the exact proven endpoint.
|
|
500
|
+
'if [ "$1" = "--resume" ]; then',
|
|
501
|
+
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" ${shQuote(server)} )" || exit 1`,
|
|
502
|
+
'else',
|
|
503
|
+
` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
|
|
504
|
+
'fi',
|
|
505
|
+
// TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
|
|
506
|
+
// codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
|
|
507
|
+
// thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
|
|
508
|
+
// does thread/start { cwd = this worktree } on the shared per-project app-server, stores the new id on the
|
|
509
|
+
// governed record (SPEXCODE_SESSION_ID), and fires the tail as the FIRST turn, materializing the rollout.
|
|
510
|
+
// Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
|
|
511
|
+
// rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
|
|
512
|
+
// literal "--resume" marker — the discriminator is unambiguous. codex-launch only prints an id once its
|
|
513
|
+
// rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
|
|
514
|
+
`if [ "$1" = "--resume" ]; then`,
|
|
515
|
+
` tid=$2`,
|
|
516
|
+
...(attachTui ? [] : [
|
|
517
|
+
// A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
|
|
518
|
+
// a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
|
|
519
|
+
`elif [ "$#" -eq 0 ]; then`,
|
|
520
|
+
` exit 0`,
|
|
521
|
+
]),
|
|
522
|
+
`else`,
|
|
523
|
+
` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@")`,
|
|
524
|
+
` __spex_rc=$?`,
|
|
525
|
+
...(attachTui ? [` [ "$__spex_rc" -eq 0 ] || exit 1`] : [
|
|
526
|
+
` if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('codex-headless')}; exit "$__spex_rc"; fi`,
|
|
527
|
+
]),
|
|
528
|
+
`fi`,
|
|
529
|
+
`[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
|
|
530
|
+
// The visible TUI is the OTHER entry point that creates an execution context for this session (a fresh
|
|
531
|
+
// launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
|
|
532
|
+
// the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
|
|
533
|
+
// creates a context stamps that context's record id, and nothing downstream re-derives it.
|
|
534
|
+
...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
|
|
535
|
+
].join('\n');
|
|
536
|
+
return `bash -lc ${shQuote(script)} spexcode-codex`;
|
|
537
|
+
}
|
|
538
|
+
function rpcError(e) {
|
|
539
|
+
return String(e?.message || e);
|
|
540
|
+
}
|
|
541
|
+
// --- minimal RFC6455 client framing ------------------------------------------------------------------------
|
|
542
|
+
// The codex app-server `--listen unix://<sock>` transport is a WebSocket endpoint at path `/rpc` (the visible
|
|
543
|
+
// `codex --remote` TUI upgrades the very same way). So we speak WebSocket over the Unix socket — NOT a raw byte
|
|
544
|
+
// stream, and NOT `codex app-server proxy` (a dumb byte relay that performs no HTTP upgrade, so the server
|
|
545
|
+
// rejects its bytes as an invalid upgrade and closes — the old 502). One JSON-RPC message = one masked text
|
|
546
|
+
// frame; the server's frames come back unmasked. We only ever exchange small frames, so this is deliberately
|
|
547
|
+
// small: text + the control frames (ping→pong, close) we must honor, plus continuation reassembly for safety.
|
|
548
|
+
function encodeWsFrame(opcode, payload) {
|
|
549
|
+
const len = payload.length;
|
|
550
|
+
const mask = randomBytes(4);
|
|
551
|
+
let header;
|
|
552
|
+
if (len < 126)
|
|
553
|
+
header = Buffer.from([0x80 | opcode, 0x80 | len]);
|
|
554
|
+
else if (len < 65536)
|
|
555
|
+
header = Buffer.from([0x80 | opcode, 0x80 | 126, (len >> 8) & 0xff, len & 0xff]);
|
|
556
|
+
else {
|
|
557
|
+
header = Buffer.alloc(10);
|
|
558
|
+
header[0] = 0x80 | opcode;
|
|
559
|
+
header[1] = 0x80 | 127;
|
|
560
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
561
|
+
}
|
|
562
|
+
const masked = Buffer.alloc(len);
|
|
563
|
+
for (let i = 0; i < len; i++)
|
|
564
|
+
masked[i] = payload[i] ^ mask[i % 4];
|
|
565
|
+
return Buffer.concat([header, mask, masked]);
|
|
566
|
+
}
|
|
567
|
+
const wsText = (s) => encodeWsFrame(0x1, Buffer.from(s, 'utf8'));
|
|
568
|
+
function drainWsFrames(s, conn, onText) {
|
|
569
|
+
for (;;) {
|
|
570
|
+
if (s.buf.length < 2)
|
|
571
|
+
return false;
|
|
572
|
+
const b0 = s.buf[0], b1 = s.buf[1], op = b0 & 0x0f, fin = (b0 & 0x80) !== 0, masked = (b1 & 0x80) !== 0;
|
|
573
|
+
let len = b1 & 0x7f, off = 2;
|
|
574
|
+
if (len === 126) {
|
|
575
|
+
if (s.buf.length < 4)
|
|
576
|
+
return false;
|
|
577
|
+
len = s.buf.readUInt16BE(2);
|
|
578
|
+
off = 4;
|
|
579
|
+
}
|
|
580
|
+
else if (len === 127) {
|
|
581
|
+
if (s.buf.length < 10)
|
|
582
|
+
return false;
|
|
583
|
+
len = Number(s.buf.readBigUInt64BE(2));
|
|
584
|
+
off = 10;
|
|
585
|
+
}
|
|
586
|
+
const dataStart = off + (masked ? 4 : 0);
|
|
587
|
+
if (s.buf.length < dataStart + len)
|
|
588
|
+
return false;
|
|
589
|
+
let payload = s.buf.slice(dataStart, dataStart + len);
|
|
590
|
+
if (masked) {
|
|
591
|
+
const mk = s.buf.slice(off, off + 4);
|
|
592
|
+
const u = Buffer.alloc(len);
|
|
593
|
+
for (let i = 0; i < len; i++)
|
|
594
|
+
u[i] = payload[i] ^ mk[i % 4];
|
|
595
|
+
payload = u;
|
|
596
|
+
}
|
|
597
|
+
s.buf = s.buf.slice(dataStart + len);
|
|
598
|
+
if (op === 0x8)
|
|
599
|
+
return true; // close
|
|
600
|
+
if (op === 0x9) {
|
|
601
|
+
conn.write(encodeWsFrame(0xa, payload));
|
|
602
|
+
continue;
|
|
603
|
+
} // ping → pong
|
|
604
|
+
if (op === 0xa)
|
|
605
|
+
continue; // pong
|
|
606
|
+
if (op === 0x0)
|
|
607
|
+
s.fragBuf = Buffer.concat([s.fragBuf, payload]); // continuation
|
|
608
|
+
else {
|
|
609
|
+
s.fragOp = op;
|
|
610
|
+
s.fragBuf = payload;
|
|
611
|
+
}
|
|
612
|
+
if (fin) {
|
|
613
|
+
if (s.fragOp === 0x1)
|
|
614
|
+
onText(s.fragBuf.toString('utf8'));
|
|
615
|
+
s.fragBuf = Buffer.alloc(0);
|
|
616
|
+
s.fragOp = 0;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const WS_UPGRADE = (key) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`;
|
|
621
|
+
const wsInitialize = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } };
|
|
622
|
+
// Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
|
|
623
|
+
// with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
|
|
624
|
+
// this observer also survives backend replacement; a thread already in systemError is reconciled from its
|
|
625
|
+
// latest turn before later live notifications take over.
|
|
626
|
+
export function codexTurnFailureObserver(rec, onFailure) {
|
|
627
|
+
const threadId = rec.harnessSessionId;
|
|
628
|
+
if (!threadId)
|
|
629
|
+
return { close: () => { }, closed: Promise.resolve(null) };
|
|
630
|
+
const runtimeDir = rec.runtimeDir || runtimeRoot();
|
|
631
|
+
const endpoint = codexEndpointForRecord(rec, runtimeDir);
|
|
632
|
+
if (!endpoint) {
|
|
633
|
+
return {
|
|
634
|
+
close: () => { },
|
|
635
|
+
closed: Promise.resolve(`Codex turn observer refused: no exact generation binding for session ${rec.session}`),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const sock = endpoint.socketPath;
|
|
639
|
+
const conn = createConnection(sock);
|
|
640
|
+
const frames = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
641
|
+
let upgraded = false, settled = false;
|
|
642
|
+
let reconciliationTimer = null;
|
|
643
|
+
let resolveClosed;
|
|
644
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
645
|
+
const cancelReconciliation = () => {
|
|
646
|
+
if (!reconciliationTimer)
|
|
647
|
+
return;
|
|
648
|
+
clearTimeout(reconciliationTimer);
|
|
649
|
+
reconciliationTimer = null;
|
|
650
|
+
};
|
|
651
|
+
const finish = (reason) => {
|
|
652
|
+
if (settled)
|
|
653
|
+
return;
|
|
654
|
+
settled = true;
|
|
655
|
+
clearTimeout(timer);
|
|
656
|
+
cancelReconciliation();
|
|
657
|
+
try {
|
|
658
|
+
conn.destroy();
|
|
659
|
+
}
|
|
660
|
+
catch { }
|
|
661
|
+
resolveClosed(reason);
|
|
662
|
+
};
|
|
663
|
+
const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000);
|
|
664
|
+
timer.unref?.();
|
|
665
|
+
const send = (message) => conn.write(wsText(JSON.stringify(message)));
|
|
666
|
+
const report = (turn, fallbackMessage) => {
|
|
667
|
+
const value = turn;
|
|
668
|
+
if (value?.status !== 'failed' && !fallbackMessage)
|
|
669
|
+
return;
|
|
670
|
+
const nativeMessage = typeof value?.error?.message === 'string' ? value.error.message.trim() : '';
|
|
671
|
+
onFailure({
|
|
672
|
+
message: nativeMessage || fallbackMessage || 'Codex turn failed',
|
|
673
|
+
completedAt: typeof value?.completedAt === 'number' && Number.isFinite(value.completedAt) ? value.completedAt : null,
|
|
674
|
+
});
|
|
675
|
+
};
|
|
676
|
+
conn.on('error', (error) => finish(`Codex turn observer connection failed: ${rpcError(error)}`));
|
|
677
|
+
conn.on('close', () => finish('Codex turn observer connection closed'));
|
|
678
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
679
|
+
const handle = (json) => {
|
|
680
|
+
let message;
|
|
681
|
+
try {
|
|
682
|
+
message = JSON.parse(json);
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (message.error)
|
|
688
|
+
return finish(`Codex turn observer request failed: ${message.error.message || JSON.stringify(message.error)}`);
|
|
689
|
+
if (message.id === 1 && message.result) {
|
|
690
|
+
send({ method: 'initialized', params: {} });
|
|
691
|
+
return send({
|
|
692
|
+
id: 2,
|
|
693
|
+
method: 'thread/resume',
|
|
694
|
+
params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } },
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
if (message.id === 2 && message.result) {
|
|
698
|
+
clearTimeout(timer);
|
|
699
|
+
const result = message.result;
|
|
700
|
+
if (result.thread?.status?.type === 'systemError') {
|
|
701
|
+
const turns = result.initialTurnsPage?.data;
|
|
702
|
+
const latest = Array.isArray(turns) ? turns[0] : null;
|
|
703
|
+
// Give a concurrently-starting turn's native notification precedence over this historical snapshot.
|
|
704
|
+
reconciliationTimer = setTimeout(() => {
|
|
705
|
+
reconciliationTimer = null;
|
|
706
|
+
report(latest, 'Codex thread entered systemError before the turn observer subscribed');
|
|
707
|
+
}, 100);
|
|
708
|
+
reconciliationTimer.unref?.();
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (message.method === 'turn/started') {
|
|
713
|
+
const params = message.params;
|
|
714
|
+
if (params?.threadId === threadId)
|
|
715
|
+
cancelReconciliation();
|
|
716
|
+
}
|
|
717
|
+
if (message.method === 'turn/completed') {
|
|
718
|
+
const params = message.params;
|
|
719
|
+
if (params?.threadId === threadId) {
|
|
720
|
+
cancelReconciliation();
|
|
721
|
+
report(params.turn);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
conn.on('data', (chunk) => {
|
|
726
|
+
frames.buf = Buffer.concat([frames.buf, chunk]);
|
|
727
|
+
if (!upgraded) {
|
|
728
|
+
const split = frames.buf.indexOf('\r\n\r\n');
|
|
729
|
+
if (split < 0)
|
|
730
|
+
return;
|
|
731
|
+
const head = frames.buf.slice(0, split).toString('utf8');
|
|
732
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
733
|
+
return finish(`Codex app-server refused turn observer: ${head.split('\r\n')[0]}`);
|
|
734
|
+
upgraded = true;
|
|
735
|
+
frames.buf = frames.buf.slice(split + 4);
|
|
736
|
+
send(wsInitialize);
|
|
737
|
+
}
|
|
738
|
+
if (drainWsFrames(frames, conn, handle))
|
|
739
|
+
finish('Codex app-server closed the turn observer');
|
|
740
|
+
});
|
|
741
|
+
return { close: () => finish(null), closed };
|
|
742
|
+
}
|
|
743
|
+
function codexThreadMutation(sock, method, threadId, fence, turnId, budgetMs = CODEX_MUTATION_BASE_MS) {
|
|
744
|
+
const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
|
|
745
|
+
? `Codex ${method} refused because the shared app-server generation changed`
|
|
746
|
+
: null;
|
|
747
|
+
const before = generationError();
|
|
748
|
+
if (before)
|
|
749
|
+
return Promise.resolve({ ok: false, error: before, commit: 'refused' });
|
|
750
|
+
if (method === 'turn/interrupt' && !turnId)
|
|
751
|
+
return Promise.resolve({ ok: false, error: 'Codex turn interrupt needs an exact turn id', commit: 'refused' });
|
|
752
|
+
return new Promise((resolve) => {
|
|
753
|
+
const conn = createConnection(sock);
|
|
754
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
755
|
+
let upgraded = false, settled = false, requested = false;
|
|
756
|
+
const done = (r) => {
|
|
757
|
+
if (settled)
|
|
758
|
+
return;
|
|
759
|
+
settled = true;
|
|
760
|
+
clearTimeout(timer);
|
|
761
|
+
try {
|
|
762
|
+
conn.destroy();
|
|
763
|
+
}
|
|
764
|
+
catch { }
|
|
765
|
+
resolve(r);
|
|
766
|
+
};
|
|
767
|
+
// Once the request is on the wire the server owns it, so every unanswered end — timeout, socket error,
|
|
768
|
+
// early close, a generation swap — leaves the commit unknown rather than refused.
|
|
769
|
+
const unanswered = (error) => done({ ok: false, error, commit: requested ? 'unknown' : 'refused' });
|
|
770
|
+
const timer = setTimeout(() => unanswered(generationError() || `Codex ${method} did not answer within ${budgetMs}ms`), budgetMs);
|
|
771
|
+
conn.on('error', (e) => unanswered(generationError() || `Codex ${method} connection failed: ${rpcError(e)}`));
|
|
772
|
+
conn.on('close', () => { if (!settled)
|
|
773
|
+
unanswered(`Codex app-server closed during ${method}`); });
|
|
774
|
+
const send = (m) => conn.write(wsText(JSON.stringify(m)));
|
|
775
|
+
conn.on('connect', () => {
|
|
776
|
+
const changed = generationError();
|
|
777
|
+
if (changed)
|
|
778
|
+
return unanswered(changed);
|
|
779
|
+
conn.write(WS_UPGRADE(randomBytes(16).toString('base64')));
|
|
780
|
+
});
|
|
781
|
+
const handle = (json) => {
|
|
782
|
+
let m;
|
|
783
|
+
try {
|
|
784
|
+
m = JSON.parse(json);
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
// The server answered by rejecting, so the target is provably unchanged whether or not we had sent it.
|
|
790
|
+
if (m.error)
|
|
791
|
+
return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}`, commit: 'refused' });
|
|
792
|
+
if (m.id === 1 && m.result) {
|
|
793
|
+
const changed = generationError();
|
|
794
|
+
if (changed)
|
|
795
|
+
return unanswered(changed);
|
|
796
|
+
send({ method: 'initialized', params: {} });
|
|
797
|
+
requested = true;
|
|
798
|
+
return send({ id: 2, method, params: method === 'turn/interrupt' ? { threadId, turnId } : { threadId } });
|
|
799
|
+
}
|
|
800
|
+
if (m.id === 2 && m.result) {
|
|
801
|
+
const changed = generationError();
|
|
802
|
+
return changed ? unanswered(changed) : done({ ok: true });
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
conn.on('data', (chunk) => {
|
|
806
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
807
|
+
if (!upgraded) {
|
|
808
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
809
|
+
if (i < 0)
|
|
810
|
+
return;
|
|
811
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
812
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
813
|
+
return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}`, commit: 'refused' });
|
|
814
|
+
upgraded = true;
|
|
815
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
816
|
+
send(wsInitialize);
|
|
817
|
+
}
|
|
818
|
+
if (drainWsFrames(fs, conn, handle))
|
|
819
|
+
unanswered(`Codex app-server closed during ${method}`);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
// Dashboard/resource probes keep their own short budget; this target-scoped census is only entered by a
|
|
824
|
+
// lifecycle mutation that already holds the session transition lock and must tolerate a busy app-server.
|
|
825
|
+
const CODEX_MUTATION_CENSUS_MS = 15_000;
|
|
826
|
+
// A mutation's response budget. `thread/unarchive` and `turn/interrupt` are state flips the server answers at
|
|
827
|
+
// once — measured 36ms to unarchive the very same 279 MB thread that took 47.7s to archive — so they keep the
|
|
828
|
+
// base. `thread/archive` on a LOADED thread differs in kind: the server flushes that thread's whole in-memory
|
|
829
|
+
// rollout inside shutdown_and_wait before it commits, so the wait is proportional to accumulated history
|
|
830
|
+
// (measured 47.7s for 279 MB, ~5.9 MB/s, against ~1.5s for a notLoaded member that flushes nothing). A fixed
|
|
831
|
+
// ceiling therefore never bounds the operation; it only picks the transcript size above which archive stops
|
|
832
|
+
// working, and raising it just moves that size. The scaled term is deliberately pessimistic — a floor rate ~6x
|
|
833
|
+
// under the measured one — because its job is to catch a WEDGED server, not to predict a flush: a machine
|
|
834
|
+
// several times slower still archives, while a hung one still fails loudly.
|
|
835
|
+
const CODEX_MUTATION_BASE_MS = 15_000;
|
|
836
|
+
const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000;
|
|
837
|
+
const codexArchiveBudgetMs = (bytes) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS);
|
|
838
|
+
// Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
|
|
839
|
+
// entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
|
|
840
|
+
// supplies every protocol source kind explicitly for its thread/list calls.
|
|
841
|
+
export const CODEX_THREAD_SOURCE_KINDS = [
|
|
842
|
+
'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
|
|
843
|
+
'subAgentThreadSpawn', 'subAgentOther', 'unknown',
|
|
844
|
+
];
|
|
845
|
+
function codexPagedIds(sock, method, params, extractId, label, onItem, timeoutMs = CODEX_MUTATION_CENSUS_MS) {
|
|
846
|
+
return new Promise((resolve) => {
|
|
847
|
+
const conn = createConnection(sock);
|
|
848
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
849
|
+
let upgraded = false, settled = false, requestId = 2, cursor = null;
|
|
850
|
+
const ids = new Set();
|
|
851
|
+
const done = (result) => {
|
|
852
|
+
if (settled)
|
|
853
|
+
return;
|
|
854
|
+
settled = true;
|
|
855
|
+
clearTimeout(timer);
|
|
856
|
+
try {
|
|
857
|
+
conn.destroy();
|
|
858
|
+
}
|
|
859
|
+
catch { }
|
|
860
|
+
;
|
|
861
|
+
resolve(result);
|
|
862
|
+
};
|
|
863
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out after ${timeoutMs}ms` }), timeoutMs);
|
|
864
|
+
conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }));
|
|
865
|
+
conn.on('close', () => { if (!settled)
|
|
866
|
+
done({ ok: false, error: `Codex app-server closed during ${label}` }); });
|
|
867
|
+
const send = (message) => conn.write(wsText(JSON.stringify(message)));
|
|
868
|
+
const requestPage = () => send({ id: requestId, method, params: { ...params, ...(cursor ? { cursor } : {}), limit: 100 } });
|
|
869
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
870
|
+
const handle = (json) => {
|
|
871
|
+
let message;
|
|
872
|
+
try {
|
|
873
|
+
message = JSON.parse(json);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (message.error)
|
|
879
|
+
return done({ ok: false, error: `Codex ${label} failed: ${message.error.message || JSON.stringify(message.error)}` });
|
|
880
|
+
if (message.id === 1 && message.result) {
|
|
881
|
+
send({ method: 'initialized', params: {} });
|
|
882
|
+
return requestPage();
|
|
883
|
+
}
|
|
884
|
+
if (message.id !== requestId || !message.result)
|
|
885
|
+
return;
|
|
886
|
+
const page = message.result;
|
|
887
|
+
if (Array.isArray(page.data))
|
|
888
|
+
for (const item of page.data) {
|
|
889
|
+
onItem?.(item);
|
|
890
|
+
const id = extractId(item);
|
|
891
|
+
if (typeof id === 'string')
|
|
892
|
+
ids.add(id);
|
|
893
|
+
}
|
|
894
|
+
cursor = typeof page.nextCursor === 'string' && page.nextCursor ? page.nextCursor : null;
|
|
895
|
+
if (!cursor)
|
|
896
|
+
return done({ ok: true, ids: [...ids] });
|
|
897
|
+
requestId++;
|
|
898
|
+
requestPage();
|
|
899
|
+
};
|
|
900
|
+
conn.on('data', (chunk) => {
|
|
901
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
902
|
+
if (!upgraded) {
|
|
903
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
904
|
+
if (i < 0)
|
|
905
|
+
return;
|
|
906
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
907
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
908
|
+
return done({ ok: false, error: `Codex app-server refused loaded-reference census: ${head.split('\r\n')[0]}` });
|
|
909
|
+
upgraded = true;
|
|
910
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
911
|
+
send(wsInitialize);
|
|
912
|
+
}
|
|
913
|
+
if (drainWsFrames(fs, conn, handle))
|
|
914
|
+
done({ ok: false, error: `Codex app-server closed during ${label}` });
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
// Lightweight resident census: unlike the full shared-runtime probe, this scans only paginated manager IDs
|
|
919
|
+
// and never issues thread/read includeTurns for each loaded reference.
|
|
920
|
+
export async function codexLoadedReferenceIds(sock) {
|
|
921
|
+
const result = await codexPagedIds(sock, 'thread/loaded/list', {}, (item) => {
|
|
922
|
+
if (typeof item === 'string')
|
|
923
|
+
return item;
|
|
924
|
+
const value = item;
|
|
925
|
+
return typeof value?.id === 'string' ? value.id : typeof value?.threadId === 'string' ? value.threadId : null;
|
|
926
|
+
}, 'loaded-reference census');
|
|
927
|
+
return result.ok ? { ok: true, referenceIds: result.ids } : result;
|
|
928
|
+
}
|
|
929
|
+
const CODEX_RUNNING_TURN_READ_MS = 15_000;
|
|
930
|
+
const CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS = 6;
|
|
931
|
+
const CODEX_COLD_PREFLIGHT_RETRY_MS = 250;
|
|
932
|
+
const CODEX_COLD_PREFLIGHT_DEADLINE_MS = 30_000;
|
|
933
|
+
async function waitForCodexGeneration(dir, endpoint) {
|
|
934
|
+
const deadline = Date.now() + CODEX_COLD_PREFLIGHT_DEADLINE_MS;
|
|
935
|
+
for (let attempt = 0; attempt < CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS; attempt++) {
|
|
936
|
+
const generation = codexRuntimeGeneration(dir, endpoint);
|
|
937
|
+
if (generation)
|
|
938
|
+
return generation;
|
|
939
|
+
if (attempt === CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS - 1 || Date.now() >= deadline)
|
|
940
|
+
break;
|
|
941
|
+
const delay = Math.min(CODEX_COLD_PREFLIGHT_RETRY_MS * 2 ** attempt, deadline - Date.now());
|
|
942
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
function codexRunningTurn(sock, threadId) {
|
|
947
|
+
return new Promise((resolve) => {
|
|
948
|
+
const conn = createConnection(sock);
|
|
949
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
950
|
+
let upgraded = false, settled = false;
|
|
951
|
+
const done = (result) => {
|
|
952
|
+
if (settled)
|
|
953
|
+
return;
|
|
954
|
+
settled = true;
|
|
955
|
+
clearTimeout(timer);
|
|
956
|
+
try {
|
|
957
|
+
conn.destroy();
|
|
958
|
+
}
|
|
959
|
+
catch { }
|
|
960
|
+
;
|
|
961
|
+
resolve(result);
|
|
962
|
+
};
|
|
963
|
+
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);
|
|
964
|
+
conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }));
|
|
965
|
+
conn.on('close', () => { if (!settled)
|
|
966
|
+
done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }); });
|
|
967
|
+
const send = (message) => conn.write(wsText(JSON.stringify(message)));
|
|
968
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
969
|
+
const handle = (json) => {
|
|
970
|
+
let message;
|
|
971
|
+
try {
|
|
972
|
+
message = JSON.parse(json);
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (message.error) {
|
|
978
|
+
const error = message.error.message || JSON.stringify(message.error);
|
|
979
|
+
if (isCodexUnmaterializedThreadError(threadId, error))
|
|
980
|
+
return done({ ok: true, turnPresence: 'idle', unmaterialized: true });
|
|
981
|
+
return done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${error}` });
|
|
982
|
+
}
|
|
983
|
+
if (message.id === 1 && message.result) {
|
|
984
|
+
send({ method: 'initialized', params: {} });
|
|
985
|
+
// The guard needs only the current turn. `thread/read {includeTurns:true}` materializes the
|
|
986
|
+
// entire persisted history, so an old but otherwise healthy thread can time out before close.
|
|
987
|
+
return send({ id: 2, method: 'thread/turns/list', params: { threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } });
|
|
988
|
+
}
|
|
989
|
+
if (message.id !== 2 || !message.result)
|
|
990
|
+
return;
|
|
991
|
+
const turns = message.result.data;
|
|
992
|
+
if (!Array.isArray(turns))
|
|
993
|
+
return done({ ok: true, turnPresence: 'unknown' });
|
|
994
|
+
const active = turns.find((turn) => !!turn && typeof turn === 'object' && turn.status === 'inProgress');
|
|
995
|
+
if (!active)
|
|
996
|
+
return done({ ok: true, turnPresence: 'idle' });
|
|
997
|
+
return typeof active.id === 'string' && active.id
|
|
998
|
+
? done({ ok: true, turnPresence: 'active', turnId: active.id })
|
|
999
|
+
: done({ ok: true, turnPresence: 'unknown' });
|
|
1000
|
+
};
|
|
1001
|
+
conn.on('data', (chunk) => {
|
|
1002
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
1003
|
+
if (!upgraded) {
|
|
1004
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
1005
|
+
if (i < 0)
|
|
1006
|
+
return;
|
|
1007
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
1008
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
1009
|
+
return done({ ok: false, error: `Codex app-server refused target thread ${threadId} turn census: ${head.split('\r\n')[0]}` });
|
|
1010
|
+
upgraded = true;
|
|
1011
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
1012
|
+
send(wsInitialize);
|
|
1013
|
+
}
|
|
1014
|
+
if (drainWsFrames(fs, conn, handle))
|
|
1015
|
+
done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` });
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
const CODEX_INTERRUPT_SETTLE_MS = 15_000;
|
|
1020
|
+
// Codex can report an exact pre-materialization refusal, or that a previously loaded id is no longer loaded.
|
|
1021
|
+
// Both are positive no-turn facts for terminal close; every transport or ownership ambiguity remains fail-closed.
|
|
1022
|
+
const isCodexUnmaterializedThreadError = (threadId, error) => (error.includes(threadId) && error.includes('is not materialized yet') &&
|
|
1023
|
+
error.includes('thread/turns/list is unavailable before first user message')) ||
|
|
1024
|
+
error.includes(`thread not loaded: ${threadId}`);
|
|
1025
|
+
async function interruptCodexTurn(rec) {
|
|
1026
|
+
if (!rec.harnessSessionId)
|
|
1027
|
+
return { ok: false, error: 'no exact Codex thread identity is registered' };
|
|
1028
|
+
const threadId = rec.harnessSessionId;
|
|
1029
|
+
const dir = rec.runtimeDir || runtimeRoot();
|
|
1030
|
+
const endpoint = codexEndpointForRecord(rec, dir);
|
|
1031
|
+
if (!endpoint)
|
|
1032
|
+
return { ok: false, error: 'no exact Codex generation binding is registered for this target' };
|
|
1033
|
+
const generation = await waitForCodexGeneration(dir, endpoint);
|
|
1034
|
+
if (!generation)
|
|
1035
|
+
return { ok: false, error: 'Codex shared app-server generation remained unproven while waiting to interrupt' };
|
|
1036
|
+
const fence = { dir, endpoint, generation };
|
|
1037
|
+
const before = await codexRunningTurn(endpoint.socketPath, threadId);
|
|
1038
|
+
if (!before.ok)
|
|
1039
|
+
return { ok: false, error: before.error };
|
|
1040
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1041
|
+
return { ok: false, error: 'shared Codex app-server generation changed during interrupt preflight' };
|
|
1042
|
+
if (before.turnPresence === 'idle')
|
|
1043
|
+
return { ok: true };
|
|
1044
|
+
if (before.turnPresence !== 'active' || !before.turnId)
|
|
1045
|
+
return { ok: false, error: `Codex target thread ${threadId} turn state is unknown` };
|
|
1046
|
+
const interrupted = await codexThreadMutation(endpoint.socketPath, 'turn/interrupt', threadId, fence, before.turnId);
|
|
1047
|
+
if (!interrupted.ok)
|
|
1048
|
+
return { ok: false, error: interrupted.error };
|
|
1049
|
+
const deadline = Date.now() + CODEX_INTERRUPT_SETTLE_MS;
|
|
1050
|
+
for (;;) {
|
|
1051
|
+
const after = await codexRunningTurn(endpoint.socketPath, threadId);
|
|
1052
|
+
if (!after.ok)
|
|
1053
|
+
return { ok: false, error: after.error };
|
|
1054
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1055
|
+
return { ok: false, error: 'shared Codex app-server generation changed during interrupt settlement' };
|
|
1056
|
+
if (after.turnPresence === 'idle')
|
|
1057
|
+
return { ok: true };
|
|
1058
|
+
if (after.turnPresence === 'unknown')
|
|
1059
|
+
return { ok: false, error: `Codex target thread ${threadId} turn state is unknown after interrupt` };
|
|
1060
|
+
if (Date.now() >= deadline)
|
|
1061
|
+
return { ok: false, error: `Codex target thread ${threadId} remained active after interrupt` };
|
|
1062
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
// The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
|
|
1066
|
+
// a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
|
|
1067
|
+
export function codexThreadList(sock, params) {
|
|
1068
|
+
return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result);
|
|
1069
|
+
}
|
|
1070
|
+
const codexRowStatus = (row) => {
|
|
1071
|
+
const type = row.status?.type;
|
|
1072
|
+
return type === 'notLoaded' || type === 'idle' || type === 'active' ? type : 'unknown';
|
|
1073
|
+
};
|
|
1074
|
+
function codexThreadCollection(sock, params) {
|
|
1075
|
+
const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
|
|
1076
|
+
? params.sourceKinds
|
|
1077
|
+
: [...CODEX_THREAD_SOURCE_KINDS];
|
|
1078
|
+
const parentById = new Map();
|
|
1079
|
+
const statusById = new Map();
|
|
1080
|
+
const conflictingParents = new Set();
|
|
1081
|
+
return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
|
|
1082
|
+
if (typeof item === 'string')
|
|
1083
|
+
return item;
|
|
1084
|
+
const id = item?.id;
|
|
1085
|
+
return typeof id === 'string' ? id : null;
|
|
1086
|
+
}, 'thread/list', (item) => {
|
|
1087
|
+
if (!item || typeof item !== 'object')
|
|
1088
|
+
return;
|
|
1089
|
+
const row = item;
|
|
1090
|
+
if (typeof row.id !== 'string')
|
|
1091
|
+
return;
|
|
1092
|
+
const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null;
|
|
1093
|
+
if (parentById.has(row.id) && parentById.get(row.id) !== parent)
|
|
1094
|
+
conflictingParents.add(row.id);
|
|
1095
|
+
parentById.set(row.id, parent);
|
|
1096
|
+
// Parent ownership is a fact about the graph, so a disagreement across pages is a census fault.
|
|
1097
|
+
// Turn state is live, so a mid-drain change is not a fault — it is simply no longer knowable here.
|
|
1098
|
+
const status = codexRowStatus(row);
|
|
1099
|
+
statusById.set(row.id, statusById.has(row.id) && statusById.get(row.id) !== status ? 'unknown' : status);
|
|
1100
|
+
}).then((result) => {
|
|
1101
|
+
if (!result.ok)
|
|
1102
|
+
return result;
|
|
1103
|
+
if (conflictingParents.size)
|
|
1104
|
+
return { ok: false, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` };
|
|
1105
|
+
return { ...result, parentById, statusById };
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
// The gate's question is about the tip — is a turn in flight right now — and thread/list already answers it
|
|
1109
|
+
// for every thread at once, at a cost that tracks how many threads exist. Reading turns instead costs the
|
|
1110
|
+
// target's whole persisted history against a fixed budget, so a long-lived session becomes unmutatable.
|
|
1111
|
+
const codexPresenceFromStatus = (status) => status === 'idle' || status === 'active' ? status : 'unknown';
|
|
1112
|
+
async function codexTargetMutationGuard(threadId, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
1113
|
+
const generationBefore = codexMutationGeneration(dir, endpoint);
|
|
1114
|
+
if (!generationBefore)
|
|
1115
|
+
return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' };
|
|
1116
|
+
const sock = endpoint.socketPath;
|
|
1117
|
+
// The descendant collections are ancestor-filtered and therefore exclude the target itself, so the
|
|
1118
|
+
// target's own turn state comes from the whole-collection census. These run concurrently with the rest.
|
|
1119
|
+
const [loaded, activeDescendants, archivedDescendants, activeList, archivedList] = await Promise.all([
|
|
1120
|
+
codexLoadedReferenceIds(sock),
|
|
1121
|
+
codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1122
|
+
codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1123
|
+
codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
|
|
1124
|
+
codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
|
|
1125
|
+
]);
|
|
1126
|
+
const referenceIds = loaded.ok ? loaded.referenceIds : [];
|
|
1127
|
+
const descendantIds = activeDescendants.ok && archivedDescendants.ok
|
|
1128
|
+
? [...new Set([...activeDescendants.ids, ...archivedDescendants.ids])]
|
|
1129
|
+
: [];
|
|
1130
|
+
if (!loaded.ok)
|
|
1131
|
+
return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error };
|
|
1132
|
+
if (!activeDescendants.ok)
|
|
1133
|
+
return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error };
|
|
1134
|
+
if (!archivedDescendants.ok)
|
|
1135
|
+
return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error };
|
|
1136
|
+
if (!activeList.ok)
|
|
1137
|
+
return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeList.error };
|
|
1138
|
+
if (!archivedList.ok)
|
|
1139
|
+
return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedList.error };
|
|
1140
|
+
const targetTurnPresence = referenceIds.includes(threadId)
|
|
1141
|
+
? codexPresenceFromStatus(activeList.statusById.get(threadId) ?? archivedList.statusById.get(threadId))
|
|
1142
|
+
: 'none';
|
|
1143
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
|
|
1144
|
+
return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' };
|
|
1145
|
+
return { healthy: true, referenceIds, targetTurnPresence, descendantIds };
|
|
1146
|
+
}
|
|
1147
|
+
const CODEX_COLD_PLAN = Symbol('codex-cold-plan');
|
|
1148
|
+
const sameIdSet = (left, right) => left.length === right.length && left.every((id) => right.includes(id));
|
|
1149
|
+
const sameParentEdges = (left, right) => left.length === right.length && left.every(([id, parent]) => right.some(([otherId, otherParent]) => id === otherId && parent === otherParent));
|
|
1150
|
+
const isCodexColdPlan = (value) => {
|
|
1151
|
+
if (!value || typeof value !== 'object')
|
|
1152
|
+
return false;
|
|
1153
|
+
const plan = value;
|
|
1154
|
+
return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
|
|
1155
|
+
typeof plan.generation === 'string' && isEndpointLike(plan.endpoint) && Array.isArray(plan.descendantIds) &&
|
|
1156
|
+
Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
|
|
1157
|
+
Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard;
|
|
1158
|
+
};
|
|
1159
|
+
function isEndpointLike(value) {
|
|
1160
|
+
return !!value && typeof value === 'object' && typeof value.id === 'string' &&
|
|
1161
|
+
typeof value.pidFile === 'string' && typeof value.receiptFile === 'string' &&
|
|
1162
|
+
typeof value.socketPath === 'string';
|
|
1163
|
+
}
|
|
1164
|
+
function makeCodexColdPlan(input) {
|
|
1165
|
+
return Object.freeze({
|
|
1166
|
+
[CODEX_COLD_PLAN]: true,
|
|
1167
|
+
kind: 'codex-cold-subtree-v1',
|
|
1168
|
+
...(input.unmaterialized ? { unmaterialized: true } : {}),
|
|
1169
|
+
threadId: input.threadId,
|
|
1170
|
+
generation: input.generation,
|
|
1171
|
+
endpoint: input.endpoint,
|
|
1172
|
+
guard: input.guard,
|
|
1173
|
+
descendantIds: Object.freeze([...input.descendantIds]),
|
|
1174
|
+
parentEdges: Object.freeze([...input.parentEdges]),
|
|
1175
|
+
subtreeIds: Object.freeze([...input.subtreeIds]),
|
|
1176
|
+
activeIds: Object.freeze([...input.activeIds]),
|
|
1177
|
+
archivedIds: Object.freeze([...input.archivedIds]),
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
async function codexColdPreflightOnce(threadId, dir = runtimeRoot(), expectedGeneration, endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
1181
|
+
const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint);
|
|
1182
|
+
if (!generation)
|
|
1183
|
+
return { ok: false, reason: 'Codex shared app-server generation is temporarily unproven before subtree census' };
|
|
1184
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1185
|
+
return { ok: false, reason: 'Codex shared app-server generation changed before subtree census' };
|
|
1186
|
+
const sock = endpoint.socketPath;
|
|
1187
|
+
const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
|
|
1188
|
+
codexLoadedReferenceIds(sock),
|
|
1189
|
+
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1190
|
+
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1191
|
+
codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
|
|
1192
|
+
codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
|
|
1193
|
+
]);
|
|
1194
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1195
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' };
|
|
1196
|
+
if (!loaded.ok)
|
|
1197
|
+
return { ok: false, reason: loaded.error };
|
|
1198
|
+
if (!activeDescendants.ok)
|
|
1199
|
+
return { ok: false, reason: activeDescendants.error };
|
|
1200
|
+
if (!archivedDescendants.ok)
|
|
1201
|
+
return { ok: false, reason: archivedDescendants.error };
|
|
1202
|
+
if (!archivedList.ok)
|
|
1203
|
+
return { ok: false, reason: archivedList.error };
|
|
1204
|
+
if (!activeList.ok)
|
|
1205
|
+
return { ok: false, reason: activeList.error };
|
|
1206
|
+
const activeDescendantSet = new Set(activeDescendants.ids);
|
|
1207
|
+
const archivedDescendantSet = new Set(archivedDescendants.ids);
|
|
1208
|
+
const duplicateDescendants = activeDescendants.ids.filter((id) => archivedDescendantSet.has(id));
|
|
1209
|
+
if (duplicateDescendants.length)
|
|
1210
|
+
return { ok: false, reason: `Codex subtree members occur in both active and archived descendant collections (${duplicateDescendants.join(', ')})` };
|
|
1211
|
+
const descendantIds = [...activeDescendants.ids, ...archivedDescendants.ids];
|
|
1212
|
+
if (descendantIds.includes(threadId))
|
|
1213
|
+
return { ok: false, reason: `Codex target ${threadId} is duplicated in its own descendant closure` };
|
|
1214
|
+
const activeSet = new Set(activeList.ids);
|
|
1215
|
+
const archivedSet = new Set(archivedList.ids);
|
|
1216
|
+
if (!activeSet.has(threadId) && !archivedSet.has(threadId) && descendantIds.length === 0) {
|
|
1217
|
+
// A Codex thread id can be registered before the server materializes its first user message. The exact
|
|
1218
|
+
// protocol refusal is the only proof that this absent native target is that startup window, rather than
|
|
1219
|
+
// an unowned/reassigned record that must stay fail-closed.
|
|
1220
|
+
const turn = await codexRunningTurn(sock, threadId);
|
|
1221
|
+
if (!turn.ok)
|
|
1222
|
+
return { ok: false, reason: turn.error };
|
|
1223
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1224
|
+
return { ok: false, reason: 'shared Codex app-server generation changed while confirming an unmaterialized thread' };
|
|
1225
|
+
if (turn.unmaterialized) {
|
|
1226
|
+
const loadedTarget = loaded.referenceIds.includes(threadId);
|
|
1227
|
+
const guard = {
|
|
1228
|
+
healthy: true,
|
|
1229
|
+
referenceIds: [...loaded.referenceIds],
|
|
1230
|
+
targetTurnPresence: 'none',
|
|
1231
|
+
descendantIds: [],
|
|
1232
|
+
};
|
|
1233
|
+
return {
|
|
1234
|
+
ok: true,
|
|
1235
|
+
...(loadedTarget ? {} : { alreadyCold: true }),
|
|
1236
|
+
receipt: makeCodexColdPlan({
|
|
1237
|
+
threadId,
|
|
1238
|
+
generation,
|
|
1239
|
+
endpoint,
|
|
1240
|
+
guard,
|
|
1241
|
+
descendantIds: [],
|
|
1242
|
+
parentEdges: [],
|
|
1243
|
+
subtreeIds: [threadId],
|
|
1244
|
+
activeIds: loadedTarget ? [threadId] : [],
|
|
1245
|
+
archivedIds: [],
|
|
1246
|
+
unmaterialized: true,
|
|
1247
|
+
}),
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const parentById = new Map([...activeDescendants.parentById, ...archivedDescendants.parentById]);
|
|
1252
|
+
const depthById = new Map();
|
|
1253
|
+
for (const id of descendantIds) {
|
|
1254
|
+
const seen = new Set([id]);
|
|
1255
|
+
let cursor = id;
|
|
1256
|
+
let depth = 0;
|
|
1257
|
+
while (cursor !== threadId) {
|
|
1258
|
+
const next = parentById.get(cursor);
|
|
1259
|
+
if (!next)
|
|
1260
|
+
return { ok: false, reason: `Codex descendant ${id} has no complete parent chain to target ${threadId} (unowned or reassigned)` };
|
|
1261
|
+
if (seen.has(next))
|
|
1262
|
+
return { ok: false, reason: `Codex descendant ${id} has a cyclic parent chain` };
|
|
1263
|
+
seen.add(next);
|
|
1264
|
+
cursor = next;
|
|
1265
|
+
depth++;
|
|
1266
|
+
}
|
|
1267
|
+
depthById.set(id, depth);
|
|
1268
|
+
}
|
|
1269
|
+
const subtreeIds = [...descendantIds, threadId];
|
|
1270
|
+
for (const id of subtreeIds) {
|
|
1271
|
+
const inActive = activeSet.has(id);
|
|
1272
|
+
const inArchived = archivedSet.has(id);
|
|
1273
|
+
if (!inActive && !inArchived)
|
|
1274
|
+
return { ok: false, reason: `Codex subtree member ${id} is absent from both native collections (unowned or reassigned)` };
|
|
1275
|
+
if (inActive && inArchived)
|
|
1276
|
+
return { ok: false, reason: `Codex subtree member ${id} occurs in both active and archived native collections` };
|
|
1277
|
+
if (id !== threadId) {
|
|
1278
|
+
const expectedActive = activeDescendantSet.has(id);
|
|
1279
|
+
if (inActive !== expectedActive)
|
|
1280
|
+
return { ok: false, reason: `Codex subtree member ${id} changed collection assignment during ownership census` };
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// Every subtree member was just proven to occur in exactly one whole-collection census, so that census
|
|
1284
|
+
// already carries each one's live turn state. No second round of native reads, and therefore no second
|
|
1285
|
+
// generation fence — nothing was read between the fence above and here.
|
|
1286
|
+
const statusById = new Map([...activeList.statusById, ...archivedList.statusById]);
|
|
1287
|
+
const loadedSet = new Set(loaded.referenceIds);
|
|
1288
|
+
const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id));
|
|
1289
|
+
for (const id of loadedSubtreeIds) {
|
|
1290
|
+
const presence = codexPresenceFromStatus(statusById.get(id));
|
|
1291
|
+
if (presence === 'active')
|
|
1292
|
+
return { ok: false, reason: `Codex subtree member ${id} has an active turn` };
|
|
1293
|
+
if (presence === 'unknown')
|
|
1294
|
+
return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` };
|
|
1295
|
+
if (archivedSet.has(id))
|
|
1296
|
+
return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` };
|
|
1297
|
+
}
|
|
1298
|
+
// Proven, not assumed: a loaded target is one of the members the loop above just cleared.
|
|
1299
|
+
const targetTurnPresence = loadedSet.has(threadId) ? 'idle' : 'none';
|
|
1300
|
+
const guard = {
|
|
1301
|
+
healthy: true,
|
|
1302
|
+
referenceIds: [...loaded.referenceIds],
|
|
1303
|
+
targetTurnPresence,
|
|
1304
|
+
descendantIds: [...descendantIds],
|
|
1305
|
+
};
|
|
1306
|
+
const activeIds = [...activeDescendants.ids]
|
|
1307
|
+
.sort((left, right) => (depthById.get(right) ?? 0) - (depthById.get(left) ?? 0))
|
|
1308
|
+
.concat(activeSet.has(threadId) ? [threadId] : []);
|
|
1309
|
+
const archivedIds = [...archivedDescendants.ids, ...(archivedSet.has(threadId) ? [threadId] : [])];
|
|
1310
|
+
const parentEdges = descendantIds.map((id) => [id, parentById.get(id)]);
|
|
1311
|
+
const receipt = makeCodexColdPlan({ threadId, generation, endpoint, guard, descendantIds, parentEdges, subtreeIds, activeIds, archivedIds });
|
|
1312
|
+
return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt };
|
|
1313
|
+
}
|
|
1314
|
+
// A busy app-server can refuse one WebSocket census while accepting the next. The refusal is transport-local,
|
|
1315
|
+
// so retry the complete proof (including generation fencing) within the terminal operation's finite budget;
|
|
1316
|
+
// semantic ownership refusals still return immediately and never turn into repeated native reads.
|
|
1317
|
+
const isTransientCodexCensusFailure = (reason) => /(?:temporarily unproven|timed out|connection|closed during|refused .*census|census failed|app-server busy)/i.test(reason);
|
|
1318
|
+
async function codexColdPreflight(threadId, dir = runtimeRoot(), expectedGeneration, endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
1319
|
+
const deadline = Date.now() + CODEX_COLD_PREFLIGHT_DEADLINE_MS;
|
|
1320
|
+
for (let attempt = 0; attempt < CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS; attempt++) {
|
|
1321
|
+
const result = await codexColdPreflightOnce(threadId, dir, expectedGeneration, endpoint);
|
|
1322
|
+
if (result.ok || !isTransientCodexCensusFailure(result.reason) || attempt === CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS - 1)
|
|
1323
|
+
return result;
|
|
1324
|
+
const remaining = deadline - Date.now();
|
|
1325
|
+
if (remaining <= 0)
|
|
1326
|
+
return result;
|
|
1327
|
+
const delay = Math.min(CODEX_COLD_PREFLIGHT_RETRY_MS * 2 ** attempt, remaining);
|
|
1328
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1329
|
+
}
|
|
1330
|
+
throw new Error('unreachable Codex cold preflight retry state');
|
|
1331
|
+
}
|
|
1332
|
+
async function codexQuarantineOrphanThread(threadId, opts) {
|
|
1333
|
+
const dir = runtimeRoot();
|
|
1334
|
+
const generation = codexMutationGeneration(dir);
|
|
1335
|
+
if (!generation)
|
|
1336
|
+
return { ok: false, reason: 'Codex shared app-server generation is unproven' };
|
|
1337
|
+
const owners = governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId);
|
|
1338
|
+
if (owners === null)
|
|
1339
|
+
return { ok: false, reason: 'governed Codex thread-owner census is unreadable' };
|
|
1340
|
+
if (owners.length)
|
|
1341
|
+
return { ok: false, reason: `Codex native thread ${threadId} has governed owner(s) ${owners.join(', ')}` };
|
|
1342
|
+
const before = await codexColdPreflight(threadId, dir, generation);
|
|
1343
|
+
if (!before.ok)
|
|
1344
|
+
return before;
|
|
1345
|
+
const plan = before.receipt;
|
|
1346
|
+
if (plan.descendantIds.length || plan.guard.descendantIds.length)
|
|
1347
|
+
return { ok: false, reason: `Codex native thread ${threadId} has descendants (${[...new Set([...plan.descendantIds, ...plan.guard.descendantIds])].join(', ')})` };
|
|
1348
|
+
if (plan.guard.targetTurnPresence === 'active' || plan.guard.targetTurnPresence === 'unknown')
|
|
1349
|
+
return { ok: false, reason: `Codex native thread ${threadId} is ${plan.guard.targetTurnPresence === 'active' ? 'active' : 'unknown'}` };
|
|
1350
|
+
if (plan.subtreeIds.length !== 1 || plan.subtreeIds[0] !== threadId)
|
|
1351
|
+
return { ok: false, reason: `Codex native thread ${threadId} has an ambiguous ownership closure` };
|
|
1352
|
+
const unchangedOwners = () => governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId);
|
|
1353
|
+
const rollback = () => codexRestoreColdPlan(plan, dir);
|
|
1354
|
+
if (plan.activeIds.length === 0) {
|
|
1355
|
+
if (plan.archivedIds.length !== 1 || plan.archivedIds[0] !== threadId || plan.guard.referenceIds.includes(threadId))
|
|
1356
|
+
return { ok: false, reason: `Codex native thread ${threadId} is not uniquely archived and unloaded` };
|
|
1357
|
+
const afterOwners = unchangedOwners();
|
|
1358
|
+
if (afterOwners === null || afterOwners.length)
|
|
1359
|
+
return { ok: false, reason: 'governed Codex thread-owner census changed during quarantine verification' };
|
|
1360
|
+
return { ok: true, audit: { adapter: 'codex', threadId, action: 'already-unloaded' }, compensate: async () => ({ ok: true }) };
|
|
1361
|
+
}
|
|
1362
|
+
if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
|
|
1363
|
+
return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` };
|
|
1364
|
+
const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId);
|
|
1365
|
+
const legacy = legacyCodexGenerationEndpoint(dir);
|
|
1366
|
+
// Quarantine archives one exact orphan, so it pays the same flush a subtree member does when that orphan is
|
|
1367
|
+
// loaded; the budget is derived the same way rather than being a second, differently-wrong constant.
|
|
1368
|
+
let orphanBudgetMs = CODEX_MUTATION_BASE_MS;
|
|
1369
|
+
if (plan.guard.referenceIds.includes(threadId)) {
|
|
1370
|
+
const rollout = codexRolloutBytes(threadId);
|
|
1371
|
+
if ('unreadable' in rollout)
|
|
1372
|
+
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` };
|
|
1373
|
+
orphanBudgetMs = codexArchiveBudgetMs(rollout.bytes);
|
|
1374
|
+
}
|
|
1375
|
+
const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation }, undefined, orphanBudgetMs);
|
|
1376
|
+
if (!archived.ok)
|
|
1377
|
+
return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}${archived.commit === 'unknown' ? '; commit state is unknown' : ''}` };
|
|
1378
|
+
const after = await codexColdPreflight(threadId, dir, generation);
|
|
1379
|
+
const failed = (reason) => ({ ok: false, reason });
|
|
1380
|
+
if (!after.ok) {
|
|
1381
|
+
const restored = await rollback();
|
|
1382
|
+
return failed(restored.ok ? after.reason : `${after.reason}; ${restored.reason}`);
|
|
1383
|
+
}
|
|
1384
|
+
const afterOwners = unchangedOwners();
|
|
1385
|
+
const afterPlan = after.receipt;
|
|
1386
|
+
const valid = afterPlan.descendantIds.length === 0 && afterPlan.subtreeIds.length === 1 && afterPlan.subtreeIds[0] === threadId &&
|
|
1387
|
+
afterPlan.activeIds.length === 0 && afterPlan.archivedIds.length === 1 && afterPlan.archivedIds[0] === threadId &&
|
|
1388
|
+
!afterPlan.guard.referenceIds.includes(threadId) && siblingIds.every((id) => afterPlan.guard.referenceIds.includes(id)) &&
|
|
1389
|
+
afterOwners !== null && afterOwners.length === 0;
|
|
1390
|
+
if (!valid) {
|
|
1391
|
+
const restored = await rollback();
|
|
1392
|
+
return failed(restored.ok ? `Codex orphan thread ${threadId} changed during archive verification` : `Codex orphan thread ${threadId} changed during archive verification; ${restored.reason}`);
|
|
1393
|
+
}
|
|
1394
|
+
return { ok: true, audit: { adapter: 'codex', threadId, action: 'archived' }, compensate: rollback };
|
|
1395
|
+
}
|
|
1396
|
+
async function codexMutationGuard(threadId, dir = runtimeRoot(), opts = {}, endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
1397
|
+
if (opts.coldReceipt === undefined)
|
|
1398
|
+
return codexTargetMutationGuard(threadId, dir, endpoint);
|
|
1399
|
+
if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
|
|
1400
|
+
return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' };
|
|
1401
|
+
if (opts.coldReceipt.endpoint.id !== endpoint.id)
|
|
1402
|
+
return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt names a different generation' };
|
|
1403
|
+
const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation, endpoint);
|
|
1404
|
+
if (!current.ok) {
|
|
1405
|
+
const guard = await codexTargetMutationGuard(threadId, dir, endpoint);
|
|
1406
|
+
return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason };
|
|
1407
|
+
}
|
|
1408
|
+
const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
|
|
1409
|
+
sameParentEdges(opts.coldReceipt.parentEdges, current.receipt.parentEdges) &&
|
|
1410
|
+
sameIdSet(opts.coldReceipt.activeIds, current.receipt.activeIds) &&
|
|
1411
|
+
sameIdSet(opts.coldReceipt.archivedIds, current.receipt.archivedIds);
|
|
1412
|
+
return {
|
|
1413
|
+
...current.receipt.guard,
|
|
1414
|
+
healthy: authorized,
|
|
1415
|
+
coldTeardownAuthorized: authorized,
|
|
1416
|
+
...(authorized ? {} : { error: 'adapter cold teardown receipt no longer matches the target subtree' }),
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
async function codexRestoreColdPlan(plan, dir = runtimeRoot()) {
|
|
1420
|
+
if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
|
|
1421
|
+
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' };
|
|
1422
|
+
const sock = plan.endpoint.socketPath;
|
|
1423
|
+
const [activeBefore, archivedBefore] = await Promise.all([
|
|
1424
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1425
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1426
|
+
]);
|
|
1427
|
+
if (!activeBefore.ok || !archivedBefore.ok)
|
|
1428
|
+
return { ok: false, reason: 'archive state is unknown and could not be reconciled' };
|
|
1429
|
+
if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
|
|
1430
|
+
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' };
|
|
1431
|
+
const activeSet = new Set(activeBefore.ids);
|
|
1432
|
+
const archivedSet = new Set(archivedBefore.ids);
|
|
1433
|
+
if (plan.archivedIds.some((id) => !archivedSet.has(id) || activeSet.has(id)))
|
|
1434
|
+
return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' };
|
|
1435
|
+
if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
|
|
1436
|
+
return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' };
|
|
1437
|
+
const fence = { dir, endpoint: plan.endpoint, generation: plan.generation };
|
|
1438
|
+
const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id));
|
|
1439
|
+
for (const id of restoreIds) {
|
|
1440
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence);
|
|
1441
|
+
if (!restored.ok)
|
|
1442
|
+
return { ok: false, reason: `compensation failed for ${id}: ${restored.error}` };
|
|
1443
|
+
}
|
|
1444
|
+
const [activeAfter, archivedAfter] = await Promise.all([
|
|
1445
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1446
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1447
|
+
]);
|
|
1448
|
+
const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir, plan.endpoint) === plan.generation &&
|
|
1449
|
+
plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
|
|
1450
|
+
plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id));
|
|
1451
|
+
return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' };
|
|
1452
|
+
}
|
|
1453
|
+
// Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
|
|
1454
|
+
// id at launch (codexStartThread → stored on the record), this is only the DELIVERY FALLBACK for a pre-existing
|
|
1455
|
+
// session whose id was never stored: it returns the first loaded thread. On a shared per-project server several
|
|
1456
|
+
// threads may be loaded, so it is no longer the deterministic capture path — the stored id is. Never throws.
|
|
1457
|
+
export function codexThreadId(sock) {
|
|
1458
|
+
return new Promise((resolve) => {
|
|
1459
|
+
const conn = createConnection(sock);
|
|
1460
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
1461
|
+
let upgraded = false, settled = false;
|
|
1462
|
+
const done = (r) => {
|
|
1463
|
+
if (settled)
|
|
1464
|
+
return;
|
|
1465
|
+
settled = true;
|
|
1466
|
+
clearTimeout(timer);
|
|
1467
|
+
try {
|
|
1468
|
+
conn.destroy();
|
|
1469
|
+
}
|
|
1470
|
+
catch { /* */ }
|
|
1471
|
+
resolve(r);
|
|
1472
|
+
};
|
|
1473
|
+
const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not list threads within 5000ms' }), 5000);
|
|
1474
|
+
conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }));
|
|
1475
|
+
conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/loaded/list was answered' }));
|
|
1476
|
+
const send = (m) => conn.write(wsText(JSON.stringify(m)));
|
|
1477
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
1478
|
+
const handle = (json) => {
|
|
1479
|
+
let m;
|
|
1480
|
+
try {
|
|
1481
|
+
m = JSON.parse(json);
|
|
1482
|
+
}
|
|
1483
|
+
catch {
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
if (m.error)
|
|
1487
|
+
return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` });
|
|
1488
|
+
if (m.id === 1 && m.result) {
|
|
1489
|
+
send({ method: 'initialized', params: {} });
|
|
1490
|
+
return send({ id: 2, method: 'thread/loaded/list', params: {} });
|
|
1491
|
+
}
|
|
1492
|
+
if (m.id === 2 && m.result) {
|
|
1493
|
+
const data = m.result.data;
|
|
1494
|
+
const ids = Array.isArray(data) ? data.filter((x) => typeof x === 'string') : [];
|
|
1495
|
+
return ids.length ? done({ ok: true, threadId: ids[0] }) : done({ ok: false, error: 'no loaded thread on the app-server socket yet (TUI still booting?)' });
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
conn.on('data', (chunk) => {
|
|
1499
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
1500
|
+
if (!upgraded) {
|
|
1501
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
1502
|
+
if (i < 0)
|
|
1503
|
+
return;
|
|
1504
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
1505
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
1506
|
+
return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` });
|
|
1507
|
+
upgraded = true;
|
|
1508
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
1509
|
+
send(wsInitialize);
|
|
1510
|
+
}
|
|
1511
|
+
if (drainWsFrames(fs, conn, handle))
|
|
1512
|
+
done({ ok: false, error: 'codex app-server sent a WebSocket close before thread/loaded/list was confirmed' });
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
// Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
|
|
1517
|
+
// they are never treated as references by themselves. A loaded thread is a control-plane reference and its
|
|
1518
|
+
// fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
|
|
1519
|
+
export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
1520
|
+
const sock = endpoint.socketPath;
|
|
1521
|
+
return (async () => {
|
|
1522
|
+
// File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
|
|
1523
|
+
// only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
|
|
1524
|
+
// root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
|
|
1525
|
+
let pid = 0;
|
|
1526
|
+
try {
|
|
1527
|
+
pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim());
|
|
1528
|
+
}
|
|
1529
|
+
catch { /* absent/stale */ }
|
|
1530
|
+
const pidLive = pid > 0 && !!processStartToken(pid);
|
|
1531
|
+
const listener = await listenerAt(sock, 800);
|
|
1532
|
+
if (!pidLive && listener === 'dead')
|
|
1533
|
+
return { healthy: true, references: [] };
|
|
1534
|
+
if (!pidLive || listener !== 'live')
|
|
1535
|
+
return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' };
|
|
1536
|
+
const generation = codexRuntimeGeneration(dir, endpoint);
|
|
1537
|
+
if (!generation)
|
|
1538
|
+
return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' };
|
|
1539
|
+
return new Promise((resolve) => {
|
|
1540
|
+
const conn = createConnection(sock);
|
|
1541
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
1542
|
+
const references = new Map();
|
|
1543
|
+
const requests = new Map();
|
|
1544
|
+
const loadedRequests = new Set();
|
|
1545
|
+
const loadedIds = new Set();
|
|
1546
|
+
let loadedRequestId = 2;
|
|
1547
|
+
let loadedCursor = null;
|
|
1548
|
+
let upgraded = false;
|
|
1549
|
+
let settled = false;
|
|
1550
|
+
let timer;
|
|
1551
|
+
const done = (result) => {
|
|
1552
|
+
if (settled)
|
|
1553
|
+
return;
|
|
1554
|
+
settled = true;
|
|
1555
|
+
clearTimeout(timer);
|
|
1556
|
+
try {
|
|
1557
|
+
conn.destroy();
|
|
1558
|
+
}
|
|
1559
|
+
catch { /* */ }
|
|
1560
|
+
resolve(result.healthy && codexRuntimeGeneration(dir, endpoint) !== generation
|
|
1561
|
+
? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
|
|
1562
|
+
: result);
|
|
1563
|
+
};
|
|
1564
|
+
const fail = (error) => done({ healthy: false, references: [...references.values()], error });
|
|
1565
|
+
timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000);
|
|
1566
|
+
conn.on('error', (e) => fail(`codex app-server ownership probe failed: ${rpcError(e)}`));
|
|
1567
|
+
conn.on('close', () => fail('codex app-server closed during ownership probe'));
|
|
1568
|
+
const send = (m) => conn.write(wsText(JSON.stringify(m)));
|
|
1569
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
1570
|
+
const handle = (json) => {
|
|
1571
|
+
let m;
|
|
1572
|
+
try {
|
|
1573
|
+
m = JSON.parse(json);
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (m.error) {
|
|
1579
|
+
if (typeof m.id === 'number' && loadedRequests.has(m.id))
|
|
1580
|
+
return fail(`codex app-server loaded/list failed: ${m.error.message || JSON.stringify(m.error)}`);
|
|
1581
|
+
const request = typeof m.id === 'number' ? requests.get(m.id) : undefined;
|
|
1582
|
+
if (request) {
|
|
1583
|
+
requests.delete(m.id);
|
|
1584
|
+
if (!requests.size)
|
|
1585
|
+
done({ healthy: true, references: [...references.values()] });
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
return fail(`codex app-server ownership request ${m.id ?? 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}`);
|
|
1589
|
+
}
|
|
1590
|
+
if (m.id === 1 && m.result) {
|
|
1591
|
+
send({ method: 'initialized', params: {} });
|
|
1592
|
+
loadedRequests.add(loadedRequestId);
|
|
1593
|
+
return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { limit: 100 } });
|
|
1594
|
+
}
|
|
1595
|
+
if (typeof m.id === 'number' && loadedRequests.has(m.id) && m.result) {
|
|
1596
|
+
loadedRequests.delete(m.id);
|
|
1597
|
+
const data = m.result.data;
|
|
1598
|
+
const ids = [...new Set(Array.isArray(data) ? data.flatMap((item) => {
|
|
1599
|
+
if (typeof item === 'string')
|
|
1600
|
+
return [item];
|
|
1601
|
+
const id = item?.id ?? item?.threadId;
|
|
1602
|
+
return typeof id === 'string' ? [id] : [];
|
|
1603
|
+
}) : [])];
|
|
1604
|
+
for (const threadId of ids)
|
|
1605
|
+
loadedIds.add(threadId);
|
|
1606
|
+
const next = m.result.nextCursor;
|
|
1607
|
+
loadedCursor = typeof next === 'string' && next ? next : null;
|
|
1608
|
+
if (loadedCursor) {
|
|
1609
|
+
loadedRequestId++;
|
|
1610
|
+
loadedRequests.add(loadedRequestId);
|
|
1611
|
+
return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { cursor: loadedCursor, limit: 100 } });
|
|
1612
|
+
}
|
|
1613
|
+
// Continue with the complete paginated set, not just the first manager page.
|
|
1614
|
+
if (!loadedIds.size)
|
|
1615
|
+
return done({ healthy: true, references: [] });
|
|
1616
|
+
loadedIds.forEach((threadId) => {
|
|
1617
|
+
const id = 100 + requests.size;
|
|
1618
|
+
references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' });
|
|
1619
|
+
requests.set(id, threadId);
|
|
1620
|
+
send({ id, method: 'thread/read', params: { threadId, includeTurns: true } });
|
|
1621
|
+
});
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (typeof m.id === 'number' && requests.has(m.id) && m.result) {
|
|
1625
|
+
const threadId = requests.get(m.id);
|
|
1626
|
+
requests.delete(m.id);
|
|
1627
|
+
const thread = m.result.thread;
|
|
1628
|
+
const turnId = activeTurnIdFromThread(m.result);
|
|
1629
|
+
references.set(threadId, {
|
|
1630
|
+
referenceId: threadId,
|
|
1631
|
+
turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
|
|
1632
|
+
...(turnId ? { turnId } : {}),
|
|
1633
|
+
});
|
|
1634
|
+
if (!requests.size)
|
|
1635
|
+
done({ healthy: true, references: [...references.values()] });
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
conn.on('data', (chunk) => {
|
|
1639
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
1640
|
+
if (!upgraded) {
|
|
1641
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
1642
|
+
if (i < 0)
|
|
1643
|
+
return;
|
|
1644
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
1645
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
1646
|
+
return fail(`codex app-server refused ownership probe: ${head.split('\r\n')[0]}`);
|
|
1647
|
+
upgraded = true;
|
|
1648
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
1649
|
+
send(wsInitialize);
|
|
1650
|
+
}
|
|
1651
|
+
if (drainWsFrames(fs, conn, handle))
|
|
1652
|
+
fail('codex app-server closed during ownership probe');
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
})();
|
|
1656
|
+
}
|
|
1657
|
+
// @@@ codexStartThread - the BACKEND owns the thread. On the shared PER-PROJECT app-server we `thread/start
|
|
1658
|
+
// { cwd }` (codex resolves config/hooks/AGENTS.md from that worktree cwd — exactly as claude loads CLAUDE.md
|
|
1659
|
+
// per-worktree — so one project-scoped server behaves analogously to a per-worktree launch), and the result
|
|
1660
|
+
// carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
|
|
1661
|
+
// fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
|
|
1662
|
+
// Never throws.
|
|
1663
|
+
// @@@ codexStartThreadParams - what a BACKEND-owned thread is created with. `config` is the per-request
|
|
1664
|
+
// override map carrying `bypass_hook_trust` so our hooks run and `shell_environment_policy.set` so every
|
|
1665
|
+
// command this thread spawns carries the governed record id. Typed approval/sandbox fields carry the pinned
|
|
1666
|
+
// launcher's autonomy policy; putting those flags only on the later remote TUI cannot change this thread.
|
|
1667
|
+
export function codexStartThreadParams(cwd, bypassHookTrust = false, shellEnv, policy = {}) {
|
|
1668
|
+
const config = {
|
|
1669
|
+
...(bypassHookTrust ? { bypass_hook_trust: true } : {}),
|
|
1670
|
+
...(shellEnv && Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {}),
|
|
1671
|
+
};
|
|
1672
|
+
return { ...(cwd ? { cwd } : {}), ...policy, ...(Object.keys(config).length ? { config } : {}) };
|
|
1673
|
+
}
|
|
1674
|
+
export function codexStartThread(sock, cwd, bypassHookTrust = false, shellEnv, policy = {}) {
|
|
1675
|
+
return new Promise((resolve) => {
|
|
1676
|
+
const conn = createConnection(sock);
|
|
1677
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
1678
|
+
let upgraded = false, settled = false;
|
|
1679
|
+
const done = (r) => {
|
|
1680
|
+
if (settled)
|
|
1681
|
+
return;
|
|
1682
|
+
settled = true;
|
|
1683
|
+
clearTimeout(timer);
|
|
1684
|
+
try {
|
|
1685
|
+
conn.destroy();
|
|
1686
|
+
}
|
|
1687
|
+
catch { /* */ }
|
|
1688
|
+
resolve(r);
|
|
1689
|
+
};
|
|
1690
|
+
const timer = setTimeout(() => done({ ok: false, error: 'codex app-server did not start a thread within 15000ms' }), 15000);
|
|
1691
|
+
conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }));
|
|
1692
|
+
conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/start was answered' }));
|
|
1693
|
+
const send = (m) => conn.write(wsText(JSON.stringify(m)));
|
|
1694
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
1695
|
+
const handle = (json) => {
|
|
1696
|
+
let m;
|
|
1697
|
+
try {
|
|
1698
|
+
m = JSON.parse(json);
|
|
1699
|
+
}
|
|
1700
|
+
catch {
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (m.error)
|
|
1704
|
+
return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` });
|
|
1705
|
+
if (m.id === 1 && m.result) {
|
|
1706
|
+
send({ method: 'initialized', params: {} });
|
|
1707
|
+
// thread/start's `config` is the per-request override map the app-server reads (config_manager reads
|
|
1708
|
+
// `request_overrides["bypass_hook_trust"]`) — the ONLY channel that reaches the thread config; the
|
|
1709
|
+
// `--dangerously-bypass-hook-trust` flag on the `codex app-server` invocation is INERT (the app-server
|
|
1710
|
+
// never reads it for a thread), so a BACKEND-owned thread must carry the bypass here, exactly as codex's
|
|
1711
|
+
// own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
|
|
1712
|
+
// stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
|
|
1713
|
+
// the flag uses writeCodexTrust's hash and never sees this key).
|
|
1714
|
+
// The same override map carries the thread's IDENTITY. A codex tool shell is spawned by the SHARED
|
|
1715
|
+
// app-server, so it can inherit no session id — and must not, that leak was github#76. Codex's own
|
|
1716
|
+
// `shell_environment_policy.set` injects vars into every command THIS thread spawns, so the backend
|
|
1717
|
+
// stamps the governed record id there at thread creation, the same moment and the same knowledge with
|
|
1718
|
+
// which a claude launch bakes it into its agent's env. Identity then arrives per-thread, needing no
|
|
1719
|
+
// alias, no store lookup, and no cwd anywhere downstream.
|
|
1720
|
+
return send({ id: 2, method: 'thread/start', params: codexStartThreadParams(cwd, bypassHookTrust, shellEnv, policy) });
|
|
1721
|
+
}
|
|
1722
|
+
if (m.id === 2 && m.result) {
|
|
1723
|
+
const tid = m.result?.thread?.id;
|
|
1724
|
+
return tid ? done({ ok: true, threadId: tid }) : done({ ok: false, error: 'codex thread/start returned no thread id' });
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
conn.on('data', (chunk) => {
|
|
1728
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
1729
|
+
if (!upgraded) {
|
|
1730
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
1731
|
+
if (i < 0)
|
|
1732
|
+
return;
|
|
1733
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
1734
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
1735
|
+
return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` });
|
|
1736
|
+
upgraded = true;
|
|
1737
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
1738
|
+
send(wsInitialize);
|
|
1739
|
+
}
|
|
1740
|
+
if (drainWsFrames(fs, conn, handle))
|
|
1741
|
+
done({ ok: false, error: 'codex app-server sent a WebSocket close before thread/start was confirmed' });
|
|
1742
|
+
});
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
const codexTurnConfirmMs = () => {
|
|
1746
|
+
const configured = Number(process.env.SPEXCODE_CODEX_TURN_CONFIRM_MS);
|
|
1747
|
+
return Number.isFinite(configured) && configured >= 100 ? configured : 15_000;
|
|
1748
|
+
};
|
|
1749
|
+
function sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId) {
|
|
1750
|
+
return new Promise((resolve) => {
|
|
1751
|
+
const conn = createConnection(sock);
|
|
1752
|
+
const hs = codexHandshakeMessages(threadId); // [initialize(1), initialized, thread/loaded/list(2), thread/read(3)]
|
|
1753
|
+
let buf = Buffer.alloc(0), upgraded = false, settled = false;
|
|
1754
|
+
let fragOp = 0, fragBuf = Buffer.alloc(0);
|
|
1755
|
+
let steering = false; // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
|
|
1756
|
+
const done = (r) => {
|
|
1757
|
+
if (settled)
|
|
1758
|
+
return;
|
|
1759
|
+
settled = true;
|
|
1760
|
+
clearTimeout(timer);
|
|
1761
|
+
try {
|
|
1762
|
+
conn.destroy();
|
|
1763
|
+
}
|
|
1764
|
+
catch { /* */ }
|
|
1765
|
+
resolve(r);
|
|
1766
|
+
};
|
|
1767
|
+
const unresolved = (error) => done({ ok: false, error });
|
|
1768
|
+
const timer = setTimeout(() => unresolved(`codex app-server did not confirm the turn within ${codexTurnConfirmMs()}ms`), codexTurnConfirmMs());
|
|
1769
|
+
conn.on('error', (e) => unresolved(`codex app-server connection failed: ${rpcError(e)}`));
|
|
1770
|
+
conn.on('close', () => unresolved('codex app-server closed the connection before the turn was confirmed'));
|
|
1771
|
+
const send = (m) => conn.write(wsText(JSON.stringify(m)));
|
|
1772
|
+
conn.on('connect', () => {
|
|
1773
|
+
const key = randomBytes(16).toString('base64');
|
|
1774
|
+
conn.write(`GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`);
|
|
1775
|
+
});
|
|
1776
|
+
const handle = (json) => {
|
|
1777
|
+
let m;
|
|
1778
|
+
try {
|
|
1779
|
+
m = JSON.parse(json);
|
|
1780
|
+
}
|
|
1781
|
+
catch {
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (m.error) {
|
|
1785
|
+
if (m.id === 4 && steering) // active turn ended in the read→steer window → just start a fresh turn
|
|
1786
|
+
return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId));
|
|
1787
|
+
if (m.id === 3) // thread not readable yet (a freshly-started thread is "not materialized
|
|
1788
|
+
return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId)); // before its first user message") → just turn/start
|
|
1789
|
+
return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` });
|
|
1790
|
+
}
|
|
1791
|
+
// JSON-RPC initialization is ordered. Under a quiet server the premature notification happened to win;
|
|
1792
|
+
// under shared app-server load it was ignored and every later turn waited until the old 5s wall expired.
|
|
1793
|
+
if (m.id === 1 && m.result) {
|
|
1794
|
+
send(hs[1]);
|
|
1795
|
+
return send(hs[2]);
|
|
1796
|
+
} // initialize ack → initialized → ask which threads are loaded
|
|
1797
|
+
if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
|
|
1798
|
+
const loaded = m.result?.data;
|
|
1799
|
+
if (Array.isArray(loaded) && !loaded.includes(threadId))
|
|
1800
|
+
return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — immediate poke not accepted` });
|
|
1801
|
+
return send(hs[3]); // thread is live → read it to decide steer-vs-start
|
|
1802
|
+
}
|
|
1803
|
+
if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
|
|
1804
|
+
const turnId = activeTurnIdFromThread(m.result);
|
|
1805
|
+
steering = !!turnId;
|
|
1806
|
+
return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)); // id 4: turn/steer the live turn, or turn/start
|
|
1807
|
+
}
|
|
1808
|
+
if ((m.id === 4 || m.id === 5) && m.result)
|
|
1809
|
+
return done({ ok: true }); // steer/start accepted → the model has the message
|
|
1810
|
+
};
|
|
1811
|
+
const drainFrames = () => {
|
|
1812
|
+
for (;;) {
|
|
1813
|
+
if (buf.length < 2)
|
|
1814
|
+
return;
|
|
1815
|
+
const b0 = buf[0], b1 = buf[1], op = b0 & 0x0f, fin = (b0 & 0x80) !== 0, masked = (b1 & 0x80) !== 0;
|
|
1816
|
+
let len = b1 & 0x7f, off = 2;
|
|
1817
|
+
if (len === 126) {
|
|
1818
|
+
if (buf.length < 4)
|
|
1819
|
+
return;
|
|
1820
|
+
len = buf.readUInt16BE(2);
|
|
1821
|
+
off = 4;
|
|
1822
|
+
}
|
|
1823
|
+
else if (len === 127) {
|
|
1824
|
+
if (buf.length < 10)
|
|
1825
|
+
return;
|
|
1826
|
+
len = Number(buf.readBigUInt64BE(2));
|
|
1827
|
+
off = 10;
|
|
1828
|
+
}
|
|
1829
|
+
const dataStart = off + (masked ? 4 : 0);
|
|
1830
|
+
if (buf.length < dataStart + len)
|
|
1831
|
+
return;
|
|
1832
|
+
let payload = buf.slice(dataStart, dataStart + len);
|
|
1833
|
+
if (masked) {
|
|
1834
|
+
const mk = buf.slice(off, off + 4);
|
|
1835
|
+
const u = Buffer.alloc(len);
|
|
1836
|
+
for (let i = 0; i < len; i++)
|
|
1837
|
+
u[i] = payload[i] ^ mk[i % 4];
|
|
1838
|
+
payload = u;
|
|
1839
|
+
}
|
|
1840
|
+
buf = buf.slice(dataStart + len);
|
|
1841
|
+
if (op === 0x8)
|
|
1842
|
+
return unresolved('codex app-server sent a WebSocket close before turn/start was confirmed');
|
|
1843
|
+
if (op === 0x9) {
|
|
1844
|
+
conn.write(encodeWsFrame(0xa, payload));
|
|
1845
|
+
continue;
|
|
1846
|
+
} // ping → pong
|
|
1847
|
+
if (op === 0xa)
|
|
1848
|
+
continue; // pong
|
|
1849
|
+
if (op === 0x0)
|
|
1850
|
+
fragBuf = Buffer.concat([fragBuf, payload]); // continuation
|
|
1851
|
+
else {
|
|
1852
|
+
fragOp = op;
|
|
1853
|
+
fragBuf = payload;
|
|
1854
|
+
}
|
|
1855
|
+
if (fin) {
|
|
1856
|
+
if (fragOp === 0x1)
|
|
1857
|
+
handle(fragBuf.toString('utf8'));
|
|
1858
|
+
fragBuf = Buffer.alloc(0);
|
|
1859
|
+
fragOp = 0;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
conn.on('data', (chunk) => {
|
|
1864
|
+
buf = Buffer.concat([buf, chunk]);
|
|
1865
|
+
if (!upgraded) {
|
|
1866
|
+
const i = buf.indexOf('\r\n\r\n');
|
|
1867
|
+
if (i < 0)
|
|
1868
|
+
return;
|
|
1869
|
+
const head = buf.slice(0, i).toString('utf8');
|
|
1870
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
1871
|
+
return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` });
|
|
1872
|
+
upgraded = true;
|
|
1873
|
+
buf = buf.slice(i + 4);
|
|
1874
|
+
send(hs[0]); // wait for initialize before its required initialized notification
|
|
1875
|
+
}
|
|
1876
|
+
drainFrames();
|
|
1877
|
+
});
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
// fire a turn on an owned thread over the per-project socket — the same steer-vs-start delivery the live UI
|
|
1881
|
+
// uses. The launcher calls this to materialize a freshly-started thread's rollout (the first turn = the launch
|
|
1882
|
+
// prompt), and delivery reuses it for follow-ups. Exported so the CLI's `codex-launch` can fire the first turn.
|
|
1883
|
+
export function codexTurn(sock, threadId, text, cwd, clientUserMessageId) {
|
|
1884
|
+
return sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId);
|
|
1885
|
+
}
|
|
1886
|
+
// @@@ codex rollout on disk - the visible TUI resumes a thread via `codex --remote resume <tid>`, which reads
|
|
1887
|
+
// the thread's ROLLOUT FILE (`<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<ts>-<tid>.jsonl`) — so a thread the
|
|
1888
|
+
// TUI can render is exactly one whose rollout exists on disk. VERIFIED live (real codex 0.142.5): `thread/start`
|
|
1889
|
+
// ALONE writes NO rollout — only the first fired turn materializes it; and a FRESHLY-spawned app-server accepts
|
|
1890
|
+
// thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
|
|
1891
|
+
// rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
|
|
1892
|
+
// found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
|
|
1893
|
+
// does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
|
|
1894
|
+
// (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
|
|
1895
|
+
// the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
|
|
1896
|
+
// dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
|
|
1897
|
+
// every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
|
|
1898
|
+
// "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
|
|
1899
|
+
export function codexRolloutExists(threadId, root) {
|
|
1900
|
+
return codexRolloutPath(threadId, root) !== null;
|
|
1901
|
+
}
|
|
1902
|
+
// The same day-dir walk, answering how big that rollout is. `thread/archive` on a LOADED thread flushes the
|
|
1903
|
+
// thread's in-memory rollout inside the server's shutdown_and_wait before it commits, so this size IS the work
|
|
1904
|
+
// an archive asks for; a notLoaded thread flushes nothing and its size is irrelevant. NO rollout file is a real
|
|
1905
|
+
// `0`, not an error: a thread that has started but not yet persisted (thread/start alone writes none, and a
|
|
1906
|
+
// fresh app-server lags 2-4s) has nothing to flush, so refusing it would be a false refusal. Only a file that
|
|
1907
|
+
// exists and cannot be measured is unreadable, and that fails closed rather than passing as small.
|
|
1908
|
+
export function codexRolloutBytes(threadId, root) {
|
|
1909
|
+
const path = codexRolloutPath(threadId, root);
|
|
1910
|
+
if (path) {
|
|
1911
|
+
try {
|
|
1912
|
+
return { bytes: statSync(path).size };
|
|
1913
|
+
}
|
|
1914
|
+
catch {
|
|
1915
|
+
return { unreadable: true };
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
return { bytes: 0 };
|
|
1919
|
+
}
|
|
1920
|
+
// poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
|
|
1921
|
+
// caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
|
|
1922
|
+
// exceed launch.sh's fast-fail threshold so a genuine failure exits PAST it — the retry loop then treats it as a
|
|
1923
|
+
// real end, not a daemon race, and never sprays fresh (duplicate-prompt) threads.
|
|
1924
|
+
export async function waitForCodexRollout(threadId, timeoutMs = 20000) {
|
|
1925
|
+
const deadline = Date.now() + timeoutMs;
|
|
1926
|
+
for (;;) {
|
|
1927
|
+
if (codexRolloutExists(threadId))
|
|
1928
|
+
return true;
|
|
1929
|
+
if (Date.now() >= deadline)
|
|
1930
|
+
return false;
|
|
1931
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
// codex's immediate poke uses the app-server JSON-RPC channel that also powers rich clients, never TUI typing.
|
|
1935
|
+
// The visible TUI is launched against the same project app-server Unix socket, so this injects into the same
|
|
1936
|
+
// thread the pane is showing — steering an in-progress turn or starting one if idle. A missing captured thread
|
|
1937
|
+
// id or socket makes this poke fail; there is no tmux send-keys fallback because that reports "typed", not "accepted".
|
|
1938
|
+
const pexec = promisify(execFile);
|
|
1939
|
+
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
|
|
1940
|
+
async function deliverViaCodexAppServer(rec, text) {
|
|
1941
|
+
// the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
|
|
1942
|
+
// the record picks out THIS session's thread.
|
|
1943
|
+
const runtimeDir = rec.runtimeDir ?? runtimeRoot();
|
|
1944
|
+
const endpoint = rec.harnessSessionId ? codexEndpointForRecord(rec, runtimeDir) : currentCodexGeneration(runtimeDir);
|
|
1945
|
+
if (!endpoint)
|
|
1946
|
+
return { ok: false, error: `no exact Codex generation binding for session ${rec.session} — immediate poke unavailable` };
|
|
1947
|
+
const sock = endpoint.socketPath;
|
|
1948
|
+
if (!existsSync(sock))
|
|
1949
|
+
return { ok: false, error: `no Codex app-server socket for this project — immediate poke unavailable` };
|
|
1950
|
+
// use the backend-owned thread id stored at launch; fall back to reading the one loaded thread only if it's
|
|
1951
|
+
// empty (a pre-existing session from before the id was stored).
|
|
1952
|
+
let threadId = rec.harnessSessionId;
|
|
1953
|
+
if (!threadId) {
|
|
1954
|
+
const r = await codexThreadId(sock);
|
|
1955
|
+
if (!r.ok)
|
|
1956
|
+
return { ok: false, error: `${r.error} — immediate poke unavailable` };
|
|
1957
|
+
threadId = r.threadId;
|
|
1958
|
+
}
|
|
1959
|
+
return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.mid);
|
|
1960
|
+
}
|
|
1961
|
+
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
1962
|
+
// comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
|
|
1963
|
+
// (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
|
|
1964
|
+
export function writeManagedBlock(file, body, comment = ['<!-- ', ' -->']) {
|
|
1965
|
+
const [open, close] = comment;
|
|
1966
|
+
const START = `${open}spexcode:start${close}`;
|
|
1967
|
+
const END = `${open}spexcode:end${close}`;
|
|
1968
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1969
|
+
const block = `${START}\n${body}\n${END}`;
|
|
1970
|
+
const cur = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
1971
|
+
const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`);
|
|
1972
|
+
const next = re.test(cur) ? cur.replace(re, block) : cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`;
|
|
1973
|
+
return writeFileIfChanged(file, next);
|
|
1974
|
+
}
|
|
1975
|
+
// the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
|
|
1976
|
+
// leaving every other byte of the user's file intact. When deleteIfEmpty and nothing but whitespace remains,
|
|
1977
|
+
// remove the file — it was WHOLLY ours (e.g. a CLAUDE.md that carried only the generated contract block). Same
|
|
1978
|
+
// comment-style parameter so ONE primitive un-writes every managed file. No-op when the file/block is absent.
|
|
1979
|
+
export function removeManagedBlock(file, comment = ['<!-- ', ' -->'], deleteIfEmpty = false) {
|
|
1980
|
+
if (!existsSync(file))
|
|
1981
|
+
return;
|
|
1982
|
+
const [open, close] = comment;
|
|
1983
|
+
const START = `${open}spexcode:start${close}`;
|
|
1984
|
+
const END = `${open}spexcode:end${close}`;
|
|
1985
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1986
|
+
const re = new RegExp(`\\n*${esc(START)}[\\s\\S]*?${esc(END)}\\n*`);
|
|
1987
|
+
const cur = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
1988
|
+
if (!re.test(cur))
|
|
1989
|
+
return;
|
|
1990
|
+
// remove ONLY our block plus the blank lines writeManagedBlock inserted around it; do NOT normalize the
|
|
1991
|
+
// user's OWN whitespace elsewhere — this must leave every other byte intact so it is a faithful INVERSE of
|
|
1992
|
+
// writeManagedBlock's append. A global `\n{3,}→\n\n` collapse used to sit here and mutated pre-existing
|
|
1993
|
+
// blank-line runs in the user's file, which broke the policy round-trip ([[residence]]): a mode flip
|
|
1994
|
+
// and back left a spurious one-line diff on a .gitignore that had internal blank lines. The leading-newline
|
|
1995
|
+
// strip is GUARDED the same way: it exists only for a block sitting at the TOP of the file (whose '\n'
|
|
1996
|
+
// replacement would otherwise become a leading blank) — a host file that BEGINS with its own blank lines
|
|
1997
|
+
// keeps them ([[content-filter]]'s invariant, same bug class as the shim's old unconditional strip).
|
|
1998
|
+
const atTop = (re.exec(cur)?.index ?? -1) === 0;
|
|
1999
|
+
const replaced = cur.replace(re, '\n');
|
|
2000
|
+
const out = atTop ? replaced.replace(/^\n+/, '') : replaced;
|
|
2001
|
+
if (deleteIfEmpty && !out.trim()) {
|
|
2002
|
+
rmSync(file, { force: true });
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
writeFileSync(file, out);
|
|
2006
|
+
}
|
|
2007
|
+
// the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
|
|
2008
|
+
// baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
|
|
2009
|
+
// the cli-needing handlers.
|
|
2010
|
+
function buildShim(id, events, dispatch, spex) {
|
|
2011
|
+
const cmd = (e) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`;
|
|
2012
|
+
const hooks = {};
|
|
2013
|
+
for (const e of events)
|
|
2014
|
+
hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }];
|
|
2015
|
+
return { content: JSON.stringify({ hooks }, null, 2), cmd };
|
|
2016
|
+
}
|
|
2017
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
2018
|
+
// Codex trust — the codex-rs trusted_hash, reverse-engineered + pinned. Lives in the Codex adapter (it is a
|
|
2019
|
+
// codex-only fact); Claude has no analog.
|
|
2020
|
+
// Codex trust keys + the hash use snake_case event labels (codex hook_event_key_label).
|
|
2021
|
+
const SNAKE = {
|
|
2022
|
+
SessionStart: 'session_start', UserPromptSubmit: 'user_prompt_submit', PreToolUse: 'pre_tool_use',
|
|
2023
|
+
PostToolUse: 'post_tool_use', Stop: 'stop',
|
|
2024
|
+
};
|
|
2025
|
+
// @@@ codexHookHash - the trusted_hash codex computes (from codex-rs: command_hook_hash + version_for_toml):
|
|
2026
|
+
// sha256 of the canonical (recursively key-sorted, compact) JSON of {event_name, hooks:[{type,command,timeout,
|
|
2027
|
+
// async}]}; None fields omitted. Verified against live codex 0.142.3 samples.
|
|
2028
|
+
export function codexHookHash(snakeEvent, command, timeout = 600, asyncFlag = false) {
|
|
2029
|
+
const canon = (v) => v && typeof v === 'object' && !Array.isArray(v)
|
|
2030
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])]))
|
|
2031
|
+
: Array.isArray(v) ? v.map(canon) : v;
|
|
2032
|
+
const obj = { event_name: snakeEvent, hooks: [{ type: 'command', command, timeout, async: asyncFlag }] };
|
|
2033
|
+
return 'sha256:' + createHash('sha256').update(JSON.stringify(canon(obj))).digest('hex');
|
|
2034
|
+
}
|
|
2035
|
+
// @@@ stripCodexTrustFor - remove EVERY prior definition of THIS project's codex trust from a config.toml body,
|
|
2036
|
+
// in ANY form: our own sentinel block (whatever past format its comments used), a BARE `[projects."<proj>"]`
|
|
2037
|
+
// table (codex AUTO-writes one the moment it trusts a folder interactively/`exec` — NOT sentinel-wrapped), and
|
|
2038
|
+
// any `[hooks.state."<hooksJson>:…"]` tables. This is what makes the UNCONDITIONAL write duplicate-SAFE and
|
|
2039
|
+
// SELF-HEALING: codex REFUSES to load a config.toml with a duplicate key ("duplicate key"), so a sentinel-only
|
|
2040
|
+
// replace (the old behaviour) that missed a pre-existing bare/old block APPENDED a second `[projects."<proj>"]`
|
|
2041
|
+
// and took codex fully OFFLINE (the real cause of the public-vps outage). It is TABLE-scoped and STRING-compared
|
|
2042
|
+
// (no regex escaping of the path), so other projects' trust, the shared parent tables (`[projects]`,
|
|
2043
|
+
// `[hooks.state]`), and every other config key are untouched; a skipped table's body ends at the next header,
|
|
2044
|
+
// blank, or comment, so a user comment attached to a following table is preserved.
|
|
2045
|
+
function stripCodexTrustFor(cur, proj, hooksJson) {
|
|
2046
|
+
const projHeader = `[projects."${proj}"]`;
|
|
2047
|
+
const hooksPrefix = `[hooks.state."${hooksJson}:`;
|
|
2048
|
+
const out = [];
|
|
2049
|
+
let skip = false;
|
|
2050
|
+
for (const line of cur.split('\n')) {
|
|
2051
|
+
const t = line.trim();
|
|
2052
|
+
const isHeader = /^\[\[?/.test(t); // a TOML table / array-of-tables header
|
|
2053
|
+
if (skip) {
|
|
2054
|
+
if (t === '' || t.startsWith('#') || isHeader)
|
|
2055
|
+
skip = false;
|
|
2056
|
+
else
|
|
2057
|
+
continue;
|
|
2058
|
+
} // end THIS table's body
|
|
2059
|
+
if (isHeader && (t === projHeader || t.startsWith(hooksPrefix))) {
|
|
2060
|
+
skip = true;
|
|
2061
|
+
continue;
|
|
2062
|
+
}
|
|
2063
|
+
if (t === `# spexcode:trust:${proj} (managed — do not edit)` || t === `# spexcode:trust:end:${proj}`)
|
|
2064
|
+
continue;
|
|
2065
|
+
out.push(line);
|
|
2066
|
+
}
|
|
2067
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '');
|
|
2068
|
+
}
|
|
2069
|
+
// additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
|
|
2070
|
+
// `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
|
|
2071
|
+
// self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
|
|
2072
|
+
// already-reviewed (no "Hooks need review" prompt on a persistent resume — see writeTrust). ALL prior
|
|
2073
|
+
// definitions of this project's trust (ours, bare, or old-format) are STRIPPED first, so the write can never
|
|
2074
|
+
// leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
|
|
2075
|
+
// Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
|
|
2076
|
+
// (`events` may be empty for a trust-only stamp in tests.)
|
|
2077
|
+
export function writeCodexTrust(proj, events, cmdFor) {
|
|
2078
|
+
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
2079
|
+
const file = join(home, 'config.toml');
|
|
2080
|
+
const hooksJson = join(proj, '.codex', 'hooks.json');
|
|
2081
|
+
const lines = [`[projects."${proj}"]`, 'trust_level = "trusted"'];
|
|
2082
|
+
for (const e of events) {
|
|
2083
|
+
const snake = SNAKE[e];
|
|
2084
|
+
lines.push(`[hooks.state."${hooksJson}:${snake}:0:0"]`, `trusted_hash = "${codexHookHash(snake, cmdFor(e))}"`);
|
|
2085
|
+
}
|
|
2086
|
+
const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`;
|
|
2087
|
+
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson);
|
|
2088
|
+
if (!existsSync(home))
|
|
2089
|
+
mkdirSync(home, { recursive: true });
|
|
2090
|
+
writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`);
|
|
2091
|
+
return file;
|
|
2092
|
+
}
|
|
2093
|
+
// the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
|
|
2094
|
+
// removal writeCodexTrust does before it writes, so uninstall fully clears our trust (sentinel, bare, and
|
|
2095
|
+
// hooks.state) and can never leave a half-block. No-op when the file/nothing-of-ours is absent (so it never
|
|
2096
|
+
// rewrites/normalizes a config that carries none of our trust). CODEX_HOME respected for testability.
|
|
2097
|
+
function removeCodexTrust(proj) {
|
|
2098
|
+
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
2099
|
+
const file = join(home, 'config.toml');
|
|
2100
|
+
if (!existsSync(file))
|
|
2101
|
+
return;
|
|
2102
|
+
const hooksJson = join(proj, '.codex', 'hooks.json');
|
|
2103
|
+
const cur = readFileSync(file, 'utf8');
|
|
2104
|
+
if (!cur.includes(`[projects."${proj}"]`) && !cur.includes(`[hooks.state."${hooksJson}:`) &&
|
|
2105
|
+
!cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`))
|
|
2106
|
+
return;
|
|
2107
|
+
const cleaned = stripCodexTrustFor(cur, proj, hooksJson);
|
|
2108
|
+
writeFileSync(file, cleaned ? `${cleaned}\n` : '');
|
|
2109
|
+
}
|
|
2110
|
+
// is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
|
|
2111
|
+
function isTrackedFile(proj, f) {
|
|
2112
|
+
try {
|
|
2113
|
+
git(['-C', proj, 'ls-files', '--error-unmatch', f]);
|
|
2114
|
+
return true;
|
|
2115
|
+
}
|
|
2116
|
+
catch {
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
// @@@ cleanHarness - the shared clean: the inverse of materialize's per-harness write, expressed PURELY
|
|
2121
|
+
// through the adapter's own path methods so it can never drift from what write put there. Each step is
|
|
2122
|
+
// surgical, gated on a SpexCode identity stamp: the contract files carry the managed-block sentinels; the shim
|
|
2123
|
+
// is a generated file whose command line names our `dispatch.sh`; the trust is a sentinel-delimited config
|
|
2124
|
+
// block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
|
|
2125
|
+
// blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
|
|
2126
|
+
// a sibling skill/agent the user added, and NEVER any .spec data.
|
|
2127
|
+
function cleanHarness(h, proj, arts, preserveProject = false) {
|
|
2128
|
+
// deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
|
|
2129
|
+
// that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
|
|
2130
|
+
// pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
|
|
2131
|
+
for (const f of h.contractFiles(proj))
|
|
2132
|
+
removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f));
|
|
2133
|
+
const shim = h.shimFile(proj);
|
|
2134
|
+
if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh'))
|
|
2135
|
+
rmSync(shim, { force: true });
|
|
2136
|
+
const anchor = h.worktreeHookAnchor(proj); // the linked-worktree anchor copy, same identity gate as the shim
|
|
2137
|
+
if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh'))
|
|
2138
|
+
rmSync(anchor, { force: true });
|
|
2139
|
+
if (!preserveProject)
|
|
2140
|
+
h.removeTrust(proj);
|
|
2141
|
+
const sd = h.skillDir(proj);
|
|
2142
|
+
if (sd)
|
|
2143
|
+
for (const n of arts.skills)
|
|
2144
|
+
rmSync(join(sd, n), { recursive: true, force: true });
|
|
2145
|
+
const ad = h.agentDir(proj);
|
|
2146
|
+
if (ad)
|
|
2147
|
+
for (const n of arts.agents)
|
|
2148
|
+
rmSync(join(ad, `${n}.md`), { force: true });
|
|
2149
|
+
}
|
|
2150
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
2151
|
+
// codex per-session liveness signal — a codex process live in the pane's DESCENDANT tree, NOT the pane's
|
|
2152
|
+
// foreground command name, and NOT the shared app-server socket.
|
|
2153
|
+
// @@@ paneTreeRunsCodex - the codex TUI is alive iff a codex-ish process is live SOMEWHERE in the launch
|
|
2154
|
+
// pane's descendant process tree. The pane's FOREGROUND name is NOT the signal: the pane runs
|
|
2155
|
+
// `bash <launch.sh>` → `bash -lc <codex script>` → node (the codex CLI) → the vendored `codex` binary, and
|
|
2156
|
+
// tmux's `pane_current_command` reports the OUTERMOST of those — `bash` — for the entire life of a healthy,
|
|
2157
|
+
// rendering TUI (field-confirmed on macmini and Linux). So "foreground == codex" false-read every live codex
|
|
2158
|
+
// as offline, and the earlier sock-presence check false-read a dead one as online (the SHARED per-project
|
|
2159
|
+
// app-server socket survives a failed `--remote resume`). The honest shape test: HEALTHY = codex (by whatever
|
|
2160
|
+
// name — `codex`, the vendored musl binary, or the `node` its CLI runs under) exists among the pane pid's
|
|
2161
|
+
// descendants; FAILED = the launch script's bounded retries exhausted, everything under the pane exited, and
|
|
2162
|
+
// the pane sits at the bare shell — no codex/node anywhere below it. The walk is over ONE whole-box
|
|
2163
|
+
// pid→(ppid, comm) snapshot the caller took (a single `ps` for the whole session list); missing probe data
|
|
2164
|
+
// (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
|
|
2165
|
+
// may not yet contain codex — as 'starting', not 'offline'.
|
|
2166
|
+
const CODEXISH = /^(codex|node)/i; // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
|
|
2167
|
+
// the shared descendant-tree walk: does a process matching `re` live BELOW the pane pid? (The pane pid itself
|
|
2168
|
+
// is the shell, so descendants only.) Pure over the caller's one ps snapshot.
|
|
2169
|
+
function paneTreeRuns(pane, re) {
|
|
2170
|
+
if (!pane?.panePid || !pane.procs?.size)
|
|
2171
|
+
return false;
|
|
2172
|
+
const kids = new Map();
|
|
2173
|
+
for (const [pid, p] of pane.procs) {
|
|
2174
|
+
const arr = kids.get(p.ppid);
|
|
2175
|
+
if (arr)
|
|
2176
|
+
arr.push(pid);
|
|
2177
|
+
else
|
|
2178
|
+
kids.set(p.ppid, [pid]);
|
|
2179
|
+
}
|
|
2180
|
+
const stack = [...(kids.get(pane.panePid) ?? [])]; // descendants only — the pane pid itself is the shell
|
|
2181
|
+
while (stack.length) {
|
|
2182
|
+
const pid = stack.pop();
|
|
2183
|
+
const comm = pane.procs.get(pid)?.comm ?? '';
|
|
2184
|
+
if (re.test(comm.slice(comm.lastIndexOf('/') + 1)))
|
|
2185
|
+
return true; // basename — macOS ps comm is a full path
|
|
2186
|
+
const c = kids.get(pid);
|
|
2187
|
+
if (c)
|
|
2188
|
+
stack.push(...c);
|
|
2189
|
+
}
|
|
2190
|
+
return false;
|
|
2191
|
+
}
|
|
2192
|
+
export function paneTreeRunsCodex(pane) { return paneTreeRuns(pane, CODEXISH); }
|
|
2193
|
+
// ONE whole-box pid→(ppid, comm) snapshot (a single `ps` spawn) — the table paneTreeRuns walks. Owned here
|
|
2194
|
+
// (beside its consumers) and shared with sessions.ts's liveSnapshot, so the two probe layers can never parse
|
|
2195
|
+
// ps differently. A failed/timed-out ps returns an empty table: the callers read that as not-provably-running.
|
|
2196
|
+
export async function procSnapshot(timeoutMs = 4000) {
|
|
2197
|
+
const t = new Map();
|
|
2198
|
+
let out = '';
|
|
2199
|
+
try {
|
|
2200
|
+
({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: timeoutMs, killSignal: 'SIGKILL' }));
|
|
2201
|
+
}
|
|
2202
|
+
catch {
|
|
2203
|
+
return t;
|
|
2204
|
+
}
|
|
2205
|
+
for (const line of out.split('\n')) {
|
|
2206
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
2207
|
+
if (m)
|
|
2208
|
+
t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() });
|
|
2209
|
+
}
|
|
2210
|
+
return t;
|
|
2211
|
+
}
|
|
2212
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
2213
|
+
// the two implementations.
|
|
2214
|
+
const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'];
|
|
2215
|
+
const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
|
|
2216
|
+
// the five claude-shaped events pi's generated extension SYNTHESIZES from its own lifecycle (session_start →
|
|
2217
|
+
// SessionStart, input → UserPromptSubmit, tool_call → PreToolUse, tool_result → PostToolUse, agent_end +
|
|
2218
|
+
// agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
|
|
2219
|
+
// real gap as codex.
|
|
2220
|
+
const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
|
|
2221
|
+
// z-code reads Claude-compatible hooks but has neither the idle Notification nor StopFailure lifecycle event.
|
|
2222
|
+
// This is a real harness difference, not a TODO: the existing Claude-only idle state is unavailable.
|
|
2223
|
+
const ZCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
|
|
2224
|
+
// the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
|
|
2225
|
+
// launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
|
|
2226
|
+
// the fallback for a truly-old record with NO pinned cmd and NO launcher name — compatibility must preserve
|
|
2227
|
+
// the harness's normal permission model, never silently introduce an automatic-permission flag. There is no
|
|
2228
|
+
// env/config-field resolution because launchers are ordinary named config entries ([[launcher-select]]).
|
|
2229
|
+
const claudeBaseCmd = (cmd) => cmd || 'claude';
|
|
2230
|
+
const codexBaseCmd = (cmd) => cmd || 'codex';
|
|
2231
|
+
const piBaseCmd = (cmd) => cmd || 'pi'; // pi runs tools without permission prompts — no yolo flag exists or is needed
|
|
2232
|
+
const opencodeBaseCmd = (cmd) => cmd || 'opencode';
|
|
2233
|
+
const zcodeBaseCmd = (cmd) => cmd || 'zcode';
|
|
2234
|
+
// @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
|
|
2235
|
+
// opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
|
|
2236
|
+
// prompt arg (a NEW launch → `--prompt`), or a resume marker from opencodeHarness.resumeArg: `--resume <id>`
|
|
2237
|
+
// re-attaches the owned opencode session (`--session <id>`, the SAME conversation), `--continue` re-attaches
|
|
2238
|
+
// the worktree's last session when no id was ever captured (the plugin failed before its first event). A new
|
|
2239
|
+
// launch's tail can never BE a literal marker (it's one quoted prompt), so the branch is unambiguous.
|
|
2240
|
+
export function opencodeLaunchCommand(opencodeCmd = 'opencode') {
|
|
2241
|
+
const script = [
|
|
2242
|
+
`if [ "\${1:-}" = "--resume" ]; then`,
|
|
2243
|
+
// the marker carries the owned session id — export it so the plugin can seed rootSession at load: a
|
|
2244
|
+
// resumed session re-fires NO bus event until poked, so without this the rendezvous daemon rejects
|
|
2245
|
+
// every delivery (resume-continuity A-side: continuity ✓, steerability ✗).
|
|
2246
|
+
` export SPEXCODE_OPENCODE_RESUME_ID="$2"`,
|
|
2247
|
+
` exec ${opencodeCmd} --session "$2"`,
|
|
2248
|
+
`elif [ "\${1:-}" = "--continue" ]; then`,
|
|
2249
|
+
// no owned id to seed — mark the continue-resume so the plugin knows to ask the SDK for the
|
|
2250
|
+
// reattached session (scoped to this marker so a FRESH launch can never adopt a stale session).
|
|
2251
|
+
` export SPEXCODE_OPENCODE_CONTINUE=1`,
|
|
2252
|
+
` exec ${opencodeCmd} --continue`,
|
|
2253
|
+
`elif [ -n "\${1:-}" ]; then`,
|
|
2254
|
+
` exec ${opencodeCmd} --prompt "$1"`,
|
|
2255
|
+
`else`,
|
|
2256
|
+
` exec ${opencodeCmd}`,
|
|
2257
|
+
`fi`,
|
|
2258
|
+
].join('\n');
|
|
2259
|
+
return `bash -lc ${shQuote(script)} spexcode-opencode`;
|
|
2260
|
+
}
|
|
2261
|
+
const socketListenerLiveness = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline');
|
|
2262
|
+
const socketListenerOrPidAliveLiveness = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) => (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline');
|
|
2263
|
+
const panePidLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
|
|
2264
|
+
const recordOnline = (rec) => rec.stopped ? 'offline' : 'online';
|
|
2265
|
+
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
2266
|
+
// socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
|
|
2267
|
+
// faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
|
|
2268
|
+
// per-session resource NOT scoped by the store (`SPEXCODE_HOME`) or the tmux server (`SPEXCODE_TMUX`), so an
|
|
2269
|
+
// isolated instance closing an id that also names a LIVE session elsewhere had its `kill-session` miss (that
|
|
2270
|
+
// IS namespaced) while this unlink landed (it is not) — deleting a working agent's socket out from under it.
|
|
2271
|
+
// The damage is invisible and permanent: the listener stays bound to an unlinked path, so nothing can ever
|
|
2272
|
+
// connect again (delivery fails its existsSync gate) and every probe ENOENTs, which the liveness axis reads as
|
|
2273
|
+
// PROVEN death — a live worker reading `offline`, which in turn disarms the relaunch guard.
|
|
2274
|
+
// So: poll until death is proven, then unlink. A listener still answering past the wall is somebody's live
|
|
2275
|
+
// agent — mine that failed to die, or one that was never mine — and either way it is not ours to remove: leave
|
|
2276
|
+
// it and say so. `unproven` is not proof either, so it is left too. The asymmetry is deliberate: a dead-but-
|
|
2277
|
+
// unlinked file is harmless residue the next teardown reaps, while a wrong unlink strands a working agent.
|
|
2278
|
+
const SOCK_DEATH_WALL_MS = 2000; // a killed agent releases its listener in well under this; the wall only bounds the wrong case
|
|
2279
|
+
const SOCK_DEATH_POLL_MS = 100;
|
|
2280
|
+
export const unlinkSocks = async (...paths) => {
|
|
2281
|
+
for (const path of paths) {
|
|
2282
|
+
if (!existsSync(path))
|
|
2283
|
+
continue;
|
|
2284
|
+
const deadline = Date.now() + SOCK_DEATH_WALL_MS;
|
|
2285
|
+
let probe = await listenerAt(path);
|
|
2286
|
+
while (probe !== 'dead' && Date.now() < deadline) {
|
|
2287
|
+
await new Promise((r) => setTimeout(r, SOCK_DEATH_POLL_MS));
|
|
2288
|
+
probe = await listenerAt(path);
|
|
2289
|
+
}
|
|
2290
|
+
if (probe !== 'dead') {
|
|
2291
|
+
console.warn(`spex: left ${path} in place — ${probe === 'live'
|
|
2292
|
+
? 'a listener is still answering it, so it belongs to a running agent (this teardown did not kill it, or it was never ours)'
|
|
2293
|
+
: 'the listener probe could not conclude, and death was never proven'}`);
|
|
2294
|
+
continue;
|
|
2295
|
+
}
|
|
2296
|
+
try {
|
|
2297
|
+
rmSync(path, { force: true });
|
|
2298
|
+
}
|
|
2299
|
+
catch { /* already gone */ }
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
const rendezvousLaunchEnv = (id) => [
|
|
2303
|
+
'CLAUDE_BG_BACKEND=daemon',
|
|
2304
|
+
`CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`,
|
|
2305
|
+
];
|
|
2306
|
+
const noLaunchEnv = () => [];
|
|
2307
|
+
export const claudeHarness = {
|
|
2308
|
+
id: 'claude',
|
|
2309
|
+
dispatchId: 'claude',
|
|
2310
|
+
headless: false,
|
|
2311
|
+
events: CLAUDE_EVENTS,
|
|
2312
|
+
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
2313
|
+
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
2314
|
+
executionTrace: readProjectJsonlExecutionTrace,
|
|
2315
|
+
launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
|
|
2316
|
+
baseCmd: claudeBaseCmd,
|
|
2317
|
+
oneShotTurn: (prompt, cmd) => ({ command: `${claudeBaseCmd(cmd)} -p`, stdin: prompt }), // --print reads the prompt from stdin
|
|
2318
|
+
sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
|
|
2319
|
+
sessionEnvVar: harnessIdentity('claude').sessionEnvVar,
|
|
2320
|
+
launchEnv: rendezvousLaunchEnv,
|
|
2321
|
+
shimFile: (proj) => join(proj, '.claude', 'settings.json'),
|
|
2322
|
+
shimScope: 'tree',
|
|
2323
|
+
worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
|
|
2324
|
+
contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
|
|
2325
|
+
skillDir: (proj) => join(proj, '.claude', 'skills'),
|
|
2326
|
+
agentDir: (proj) => join(proj, '.claude', 'agents'),
|
|
2327
|
+
shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
|
|
2328
|
+
writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
|
|
2329
|
+
removeTrust: () => { },
|
|
2330
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject); },
|
|
2331
|
+
slashCommands: claudeSlashCommands,
|
|
2332
|
+
// online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
|
|
2333
|
+
// the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
|
|
2334
|
+
// dead-pane-reads-working bug). See rendezvousListening.
|
|
2335
|
+
liveness: socketListenerLiveness,
|
|
2336
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
2337
|
+
deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
|
|
2338
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2339
|
+
coldRuntime: async () => ({ ok: true }),
|
|
2340
|
+
// The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
|
|
2341
|
+
// (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so skip this courtesy
|
|
2342
|
+
// poke and leave the timeline reader to show the message. Matched on the panel's own
|
|
2343
|
+
// strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
|
|
2344
|
+
// across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
|
|
2345
|
+
deliveryBlockedBy: (paneText) => paneText.includes('describe a task for a new session') || (paneText.includes('enter to return') && paneText.includes('space to reply'))
|
|
2346
|
+
? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
|
|
2347
|
+
: null,
|
|
2348
|
+
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
2349
|
+
// claude's settled launch failures, in its own words: a `--resume` id it has no conversation for (the id was
|
|
2350
|
+
// never claude's, or its transcript is gone), and a rejected credential. Both are the same command failing
|
|
2351
|
+
// the same way every time — the human must repair the conversation or the login, so the transport stops at
|
|
2352
|
+
// one attempt and shows this line instead of burying it under two more identical failures.
|
|
2353
|
+
fatalLaunchOutput: ['No conversation found with session ID', 'Invalid API key', 'Please run /login'],
|
|
2354
|
+
};
|
|
2355
|
+
// Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
|
|
2356
|
+
// reused by object composition; the whole runtime half is replaced by the stream-json controller.
|
|
2357
|
+
export const claudeHeadlessHarness = {
|
|
2358
|
+
...claudeHarness,
|
|
2359
|
+
id: 'claude-headless',
|
|
2360
|
+
sessionEnvVar: harnessIdentity('claude-headless').sessionEnvVar,
|
|
2361
|
+
headless: true,
|
|
2362
|
+
runtimeOwnership: 'adapter',
|
|
2363
|
+
ownsRendezvous: false,
|
|
2364
|
+
paneTitleIsSelfSummary: false,
|
|
2365
|
+
launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
|
|
2366
|
+
launchEnv: noLaunchEnv,
|
|
2367
|
+
// Liveness is the intact, non-stopped record's property. A missing controller/child fails loudly at control
|
|
2368
|
+
// time rather than turning an idle (no child) session into a speculative offline row.
|
|
2369
|
+
liveness: recordOnline,
|
|
2370
|
+
deliver: deliverViaClaudeHeadless,
|
|
2371
|
+
interrupt: interruptClaudeHeadless,
|
|
2372
|
+
cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
|
|
2373
|
+
coldRuntime: async () => ({ ok: false, reason: 'claude-headless has no exact resident unload verification' }),
|
|
2374
|
+
deliveryBlockedBy: undefined,
|
|
2375
|
+
};
|
|
2376
|
+
function codexRuntimeDescriptor(endpoint, runtimeDir) {
|
|
2377
|
+
return {
|
|
2378
|
+
key: codexDescriptorKey(endpoint),
|
|
2379
|
+
label: endpoint.id === 'legacy' ? 'Codex app-server' : `Codex app-server ${endpoint.id.slice(0, 18)}`,
|
|
2380
|
+
pidFile: endpoint.pidFile,
|
|
2381
|
+
receiptFile: endpoint.receiptFile,
|
|
2382
|
+
residency: async () => {
|
|
2383
|
+
let pid = 0;
|
|
2384
|
+
try {
|
|
2385
|
+
pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim());
|
|
2386
|
+
}
|
|
2387
|
+
catch { /* stale/missing pid */ }
|
|
2388
|
+
const pidLive = pid > 0 && !!processStartToken(pid);
|
|
2389
|
+
const listener = await listenerAt(endpoint.socketPath, 800);
|
|
2390
|
+
if (!pidLive && listener === 'dead')
|
|
2391
|
+
return { healthy: true, referenceIds: [], rootAbsent: true };
|
|
2392
|
+
if (pidLive && (!codexRuntimeGeneration(runtimeDir, endpoint) || listener !== 'live'))
|
|
2393
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' };
|
|
2394
|
+
if (!pidLive || listener !== 'live')
|
|
2395
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' };
|
|
2396
|
+
const result = await codexLoadedReferenceIds(endpoint.socketPath);
|
|
2397
|
+
return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error };
|
|
2398
|
+
},
|
|
2399
|
+
mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts, endpoint),
|
|
2400
|
+
probe: () => codexSharedRuntimeProbe(runtimeDir, endpoint),
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
function codexRuntimeDescriptors(runtimeDir) {
|
|
2404
|
+
const endpoints = codexGenerationEndpoints(runtimeDir);
|
|
2405
|
+
return (endpoints.length ? endpoints : [legacyCodexGenerationEndpoint(runtimeDir)])
|
|
2406
|
+
.map((endpoint) => codexRuntimeDescriptor(endpoint, runtimeDir));
|
|
2407
|
+
}
|
|
2408
|
+
export const codexHarness = {
|
|
2409
|
+
id: 'codex',
|
|
2410
|
+
dispatchId: 'codex',
|
|
2411
|
+
headless: false,
|
|
2412
|
+
sharedRuntimeSpawn: true,
|
|
2413
|
+
events: CODEX_EVENTS,
|
|
2414
|
+
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
2415
|
+
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
2416
|
+
executionTrace: readCodexExecutionTrace,
|
|
2417
|
+
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
|
|
2418
|
+
baseCmd: codexBaseCmd,
|
|
2419
|
+
oneShotTurn: (prompt, cmd) => ({ command: `${codexBaseCmd(cmd)} exec -`, stdin: prompt }), // `exec -` reads the prompt from stdin
|
|
2420
|
+
sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
|
|
2421
|
+
sessionEnvVar: harnessIdentity('codex').sessionEnvVar,
|
|
2422
|
+
launchEnv: noLaunchEnv,
|
|
2423
|
+
// Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
|
|
2424
|
+
// (codex-rs `root_checkout_hooks_folder_for_dir` rewrites the hooks-config folder to <repo_root>/<rel>/.codex
|
|
2425
|
+
// for any linked worktree). Every worktree's thread (cwd = worktree root) therefore reads the SAME
|
|
2426
|
+
// <mainCheckout>/.codex/hooks.json — so the codex hooks shim + its trust materialize at the MAIN checkout
|
|
2427
|
+
// (one per project, mirroring the per-project runtime tier), while the AGENTS.md contract + skills stay
|
|
2428
|
+
// per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
|
|
2429
|
+
// cwd, so one shared shim serves every worktree.
|
|
2430
|
+
shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
|
|
2431
|
+
shimScope: 'project',
|
|
2432
|
+
// a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
|
|
2433
|
+
// the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
|
|
2434
|
+
// root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
|
|
2435
|
+
// was never built). Its content is ignored (the rewrite reads the root's shim above), so it is a pure anchor.
|
|
2436
|
+
// Only for a genuine worktree: on the main checkout, shimFile already wrote `.codex/hooks.json` there.
|
|
2437
|
+
worktreeHookAnchor: (proj) => (mainCheckout(proj) === proj ? null : join(proj, '.codex', 'hooks.json')),
|
|
2438
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')],
|
|
2439
|
+
skillDir: (proj) => join(proj, '.codex', 'skills'),
|
|
2440
|
+
agentDir: () => null, // codex has no file-discovered agent-definition primitive — materialize skips it
|
|
2441
|
+
shim: (dispatch, spex) => buildShim('codex', CODEX_EVENTS, dispatch, spex),
|
|
2442
|
+
// Write the FULL codex trust — BOTH tiers, UNCONDITIONALLY — because `bypass_hook_trust` covers neither on
|
|
2443
|
+
// the dispatched-worker path:
|
|
2444
|
+
// (1) PROJECT trust (`[projects."<mainCheckout>"] trust_level = "trusted"`) ENABLES the project config
|
|
2445
|
+
// layer — the precondition for codex to DISCOVER our hooks AT ALL. codex-rs `get_layers` drops a
|
|
2446
|
+
// disabled (untrusted) project layer BEFORE hook discovery runs, and bypass_hook_trust is read only
|
|
2447
|
+
// AFTER, per-handler — so it can NEVER enable a layer. A dispatched worker's app-server does NOT
|
|
2448
|
+
// auto-trust the project (only the interactive TUI / `codex exec` approval flow does), so without this
|
|
2449
|
+
// an untrusted worktree thread fires ZERO hooks ("Project-local config, hooks … are disabled until the
|
|
2450
|
+
// project is trusted").
|
|
2451
|
+
// (2) per-HOOK trust (the reverse-engineered `trusted_hash` blocks — codexHookHash) marks each hook Trusted
|
|
2452
|
+
// so it is NOT "new or changed". This is REQUIRED even though the launch carries
|
|
2453
|
+
// `--dangerously-bypass-hook-trust`: our visible TUI attaches to the backend-owned thread via `codex …
|
|
2454
|
+
// resume <tid>`, and codex-rs FORCES the startup hook-review prompt on a PERSISTENT RESUME regardless of
|
|
2455
|
+
// the flag (`bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume`,
|
|
2456
|
+
// tui/src/lib.rs) — an untrusted/modified hook (no matching hash) leaves the worker WEDGED at an
|
|
2457
|
+
// interactive "Hooks need review" menu. Matching hashes make review_needed_count == 0, so codex skips
|
|
2458
|
+
// the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
|
|
2459
|
+
// as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
|
|
2460
|
+
// thread still runs the hooks); it does not REPLACE the hashes here.
|
|
2461
|
+
writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
|
|
2462
|
+
// trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
|
|
2463
|
+
removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
|
|
2464
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject); },
|
|
2465
|
+
slashCommands: codexSlashCommands,
|
|
2466
|
+
// online iff the tmux window is up AND the agent is live. PRIMARY: the launch-registered `agent.pid` hot-tier
|
|
2467
|
+
// verdict (`pidAlive`) — a 100ms syscall (kill-0), no ps scan. LEGACY: a pre-registration session has no
|
|
2468
|
+
// agent.pid (`pidAlive` undefined) → fall back to the whole-box ps DESCENDANT-tree walk (paneTreeRunsCodex):
|
|
2469
|
+
// a codex-ish process live below the pane pid, NOT the pane's foreground command (that is `bash`, the launch
|
|
2470
|
+
// wrapper, even while the TUI renders — the field-confirmed false-OFFLINE) and NOT the app-server socket
|
|
2471
|
+
// (SHARED per-project, it survives a failed `--remote resume` — the earlier false-ONLINE). The legacy path
|
|
2472
|
+
// self-extinguishes as pre-registration sessions close.
|
|
2473
|
+
liveness: (_rec, tmuxAlive, _runtimeDir, pane) => {
|
|
2474
|
+
if (!tmuxAlive)
|
|
2475
|
+
return 'offline';
|
|
2476
|
+
if (pane?.pidAlive !== undefined)
|
|
2477
|
+
return pane.pidAlive ? 'online' : 'offline';
|
|
2478
|
+
return paneTreeRunsCodex(pane) ? 'online' : 'offline';
|
|
2479
|
+
},
|
|
2480
|
+
leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
|
|
2481
|
+
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
2482
|
+
observeTurnFailures: codexTurnFailureObserver,
|
|
2483
|
+
interrupt: interruptCodexTurn,
|
|
2484
|
+
cleanupRuntime: async () => { },
|
|
2485
|
+
targetDescriptorKey: (rec) => {
|
|
2486
|
+
const endpoint = codexEndpointForRecord(rec);
|
|
2487
|
+
return endpoint ? codexDescriptorKey(endpoint) : null;
|
|
2488
|
+
},
|
|
2489
|
+
coldRetirementPreflight: async (rec) => {
|
|
2490
|
+
if (!rec.harnessSessionId)
|
|
2491
|
+
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
2492
|
+
const threadId = rec.harnessSessionId;
|
|
2493
|
+
const dir = runtimeRoot();
|
|
2494
|
+
const endpoint = codexEndpointForRecord(rec, dir);
|
|
2495
|
+
if (!endpoint)
|
|
2496
|
+
return { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
2497
|
+
const result = await codexColdPreflight(threadId, dir, undefined, endpoint);
|
|
2498
|
+
if (!result.ok)
|
|
2499
|
+
return result;
|
|
2500
|
+
const generationBefore = result.receipt.generation;
|
|
2501
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
|
|
2502
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' };
|
|
2503
|
+
if (!result.alreadyCold)
|
|
2504
|
+
return { ok: false, reason: `Codex target subtree ${result.receipt.activeIds.join(', ')} is not fully archived` };
|
|
2505
|
+
return { ok: true, alreadyCold: true };
|
|
2506
|
+
},
|
|
2507
|
+
coldPreflight: async (rec) => {
|
|
2508
|
+
if (!rec.harnessSessionId)
|
|
2509
|
+
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
2510
|
+
const endpoint = codexEndpointForRecord(rec);
|
|
2511
|
+
return endpoint ? codexColdPreflight(rec.harnessSessionId, runtimeRoot(), undefined, endpoint)
|
|
2512
|
+
: { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
2513
|
+
},
|
|
2514
|
+
coldRuntime: async (rec, suppliedReceipt) => {
|
|
2515
|
+
if (!rec.harnessSessionId)
|
|
2516
|
+
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
2517
|
+
const threadId = rec.harnessSessionId;
|
|
2518
|
+
const dir = runtimeRoot();
|
|
2519
|
+
const endpoint = codexEndpointForRecord(rec, dir);
|
|
2520
|
+
if (!endpoint)
|
|
2521
|
+
return { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
2522
|
+
const sock = endpoint.socketPath;
|
|
2523
|
+
if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
|
|
2524
|
+
return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' };
|
|
2525
|
+
const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null;
|
|
2526
|
+
if (frozenPlan && (frozenPlan.endpoint.id !== endpoint.id || codexRuntimeGeneration(dir, endpoint) !== frozenPlan.generation))
|
|
2527
|
+
return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' };
|
|
2528
|
+
const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation, endpoint);
|
|
2529
|
+
if (!preflight.ok)
|
|
2530
|
+
return preflight;
|
|
2531
|
+
const plan = frozenPlan ?? preflight.receipt;
|
|
2532
|
+
if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
|
|
2533
|
+
!sameParentEdges(frozenPlan.parentEdges, preflight.receipt.parentEdges) ||
|
|
2534
|
+
!sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
|
|
2535
|
+
!sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
|
|
2536
|
+
return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' };
|
|
2537
|
+
if (codexRuntimeGeneration(dir, endpoint) !== plan.generation)
|
|
2538
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' };
|
|
2539
|
+
if (plan.activeIds.length === 0)
|
|
2540
|
+
return { ok: true };
|
|
2541
|
+
const subtreeSet = new Set(plan.subtreeIds);
|
|
2542
|
+
const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId));
|
|
2543
|
+
const fence = { dir, endpoint, generation: plan.generation };
|
|
2544
|
+
const compensate = async (reason) => {
|
|
2545
|
+
const restored = await codexRestoreColdPlan(plan, dir);
|
|
2546
|
+
return { ok: false, reason: restored.ok ? reason : `${reason}; ${restored.reason}` };
|
|
2547
|
+
};
|
|
2548
|
+
const coldCheck = async () => {
|
|
2549
|
+
const after = await codexColdPreflight(threadId, dir, plan.generation, endpoint);
|
|
2550
|
+
if (!after.ok)
|
|
2551
|
+
return after;
|
|
2552
|
+
if (codexRuntimeGeneration(dir, endpoint) !== plan.generation)
|
|
2553
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during archive' };
|
|
2554
|
+
if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
|
|
2555
|
+
return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` };
|
|
2556
|
+
if (after.receipt.activeIds.length)
|
|
2557
|
+
return { ok: false, reason: `Codex target subtree remains in the active collection (${after.receipt.activeIds.join(', ')})` };
|
|
2558
|
+
if (plan.unmaterialized) {
|
|
2559
|
+
if (after.receipt.archivedIds.length || after.receipt.guard.referenceIds.includes(threadId))
|
|
2560
|
+
return { ok: false, reason: 'unmaterialized Codex target remains in native state after delete' };
|
|
2561
|
+
return { ok: true };
|
|
2562
|
+
}
|
|
2563
|
+
if (!sameIdSet(plan.subtreeIds, after.receipt.archivedIds))
|
|
2564
|
+
return { ok: false, reason: 'Codex target subtree is not uniquely archived after cold teardown' };
|
|
2565
|
+
const afterIds = new Set(after.receipt.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId)));
|
|
2566
|
+
if (siblingBefore.some((referenceId) => !afterIds.has(referenceId)))
|
|
2567
|
+
return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' };
|
|
2568
|
+
return { ok: true };
|
|
2569
|
+
};
|
|
2570
|
+
const loadedSet = new Set(plan.guard.referenceIds);
|
|
2571
|
+
for (const id of plan.activeIds) {
|
|
2572
|
+
// Only a loaded member pays the rollout flush, and an unreadable size must not become a small budget,
|
|
2573
|
+
// so it fails closed before the server is asked to mutate anything.
|
|
2574
|
+
let budgetMs = CODEX_MUTATION_BASE_MS;
|
|
2575
|
+
if (loadedSet.has(id) && !plan.unmaterialized) {
|
|
2576
|
+
const rollout = codexRolloutBytes(id);
|
|
2577
|
+
if ('unreadable' in rollout)
|
|
2578
|
+
return compensate(`Codex subtree member ${id} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown`);
|
|
2579
|
+
budgetMs = codexArchiveBudgetMs(rollout.bytes);
|
|
2580
|
+
}
|
|
2581
|
+
const archived = await codexThreadMutation(sock, plan.unmaterialized ? 'thread/delete' : 'thread/archive', id, fence, undefined, budgetMs);
|
|
2582
|
+
if (archived.ok)
|
|
2583
|
+
continue;
|
|
2584
|
+
const action = plan.unmaterialized ? 'deleting' : 'archiving';
|
|
2585
|
+
const reason = `${archived.error} while ${action} Codex subtree member ${id}`;
|
|
2586
|
+
// Compensating an unknown commit is what turns one slow member into a false "compensation failed": the
|
|
2587
|
+
// unarchive queues behind an archive the server is still executing and times out too. Report the unknown
|
|
2588
|
+
// commit instead — that is the recovery token resume already reconciles.
|
|
2589
|
+
if (archived.commit === 'unknown')
|
|
2590
|
+
return { ok: false, reason: `${reason}; commit state is unknown and no compensation was attempted` };
|
|
2591
|
+
return compensate(reason);
|
|
2592
|
+
}
|
|
2593
|
+
let verified = { ok: false, reason: 'Codex archive verification timed out' };
|
|
2594
|
+
const verifyDeadline = Date.now() + 30_000;
|
|
2595
|
+
for (let attempt = 0; attempt < 6 && Date.now() < verifyDeadline; attempt++) {
|
|
2596
|
+
verified = await coldCheck();
|
|
2597
|
+
if (verified.ok)
|
|
2598
|
+
break;
|
|
2599
|
+
if (Date.now() < verifyDeadline)
|
|
2600
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2601
|
+
}
|
|
2602
|
+
if (verified.ok)
|
|
2603
|
+
return verified;
|
|
2604
|
+
return compensate(verified.reason);
|
|
2605
|
+
},
|
|
2606
|
+
quarantineOrphanThread: codexQuarantineOrphanThread,
|
|
2607
|
+
restoreRuntime: async (rec, suppliedReceipt) => {
|
|
2608
|
+
if (!rec.harnessSessionId)
|
|
2609
|
+
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
2610
|
+
if (suppliedReceipt !== undefined) {
|
|
2611
|
+
if (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== rec.harnessSessionId)
|
|
2612
|
+
return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' };
|
|
2613
|
+
if (suppliedReceipt.unmaterialized)
|
|
2614
|
+
return { ok: true };
|
|
2615
|
+
return codexRestoreColdPlan(suppliedReceipt);
|
|
2616
|
+
}
|
|
2617
|
+
const endpoint = codexEndpointForRecord(rec);
|
|
2618
|
+
if (!endpoint)
|
|
2619
|
+
return { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
2620
|
+
const sock = endpoint.socketPath;
|
|
2621
|
+
const reconcile = async () => {
|
|
2622
|
+
const [active, archived] = await Promise.all([
|
|
2623
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
2624
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
2625
|
+
]);
|
|
2626
|
+
if (!active.ok || !archived.ok)
|
|
2627
|
+
return { ok: false, reason: 'Codex restore state could not be reconciled' };
|
|
2628
|
+
const inActive = active.ids.includes(rec.harnessSessionId);
|
|
2629
|
+
const inArchived = archived.ids.includes(rec.harnessSessionId);
|
|
2630
|
+
if (inActive && !inArchived)
|
|
2631
|
+
return { ok: true };
|
|
2632
|
+
if (inArchived && !inActive)
|
|
2633
|
+
return { ok: false, reason: 'Codex thread remains archived; restore can be retried' };
|
|
2634
|
+
return { ok: false, reason: 'Codex restore state is ambiguous (thread in both or neither collection)' };
|
|
2635
|
+
};
|
|
2636
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId);
|
|
2637
|
+
if (!restored.ok)
|
|
2638
|
+
return reconcile();
|
|
2639
|
+
return reconcile();
|
|
2640
|
+
},
|
|
2641
|
+
sharedRuntimes: codexRuntimeDescriptors,
|
|
2642
|
+
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
2643
|
+
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
2644
|
+
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
2645
|
+
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
2646
|
+
// codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
|
|
2647
|
+
// that says so has already decided. (Its transient sibling — the rollout still being written — is handled
|
|
2648
|
+
// BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
|
|
2649
|
+
fatalLaunchOutput: ['no rollout found for thread id'],
|
|
2650
|
+
};
|
|
2651
|
+
const sameCodexHeadlessReadinessProof = (left, right) => left.kind === right.kind &&
|
|
2652
|
+
left.descriptorKey === right.descriptorKey &&
|
|
2653
|
+
codexRuntimeGenerationToken(left.generation) === codexRuntimeGenerationToken(right.generation) &&
|
|
2654
|
+
left.target.sessionId === right.target.sessionId &&
|
|
2655
|
+
left.target.threadId === right.target.threadId &&
|
|
2656
|
+
left.target.ownerSessionId === right.target.ownerSessionId &&
|
|
2657
|
+
left.target.ownerCount === right.target.ownerCount &&
|
|
2658
|
+
left.target.ownerState === right.target.ownerState &&
|
|
2659
|
+
left.target.referenceState === right.target.referenceState &&
|
|
2660
|
+
left.target.protectsControlPlane === right.target.protectsControlPlane;
|
|
2661
|
+
const governedSharedRuntimeOwners = (runtimeDir, descriptorKey, threadId, excludingSessionId) => {
|
|
2662
|
+
const root = join(runtimeDir, 'sessions');
|
|
2663
|
+
let entries;
|
|
2664
|
+
try {
|
|
2665
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
2666
|
+
}
|
|
2667
|
+
catch (error) {
|
|
2668
|
+
return error.code === 'ENOENT' ? [] : null;
|
|
2669
|
+
}
|
|
2670
|
+
const owners = [];
|
|
2671
|
+
for (const entry of entries) {
|
|
2672
|
+
if (!entry.isDirectory())
|
|
2673
|
+
continue;
|
|
2674
|
+
if (entry.name === excludingSessionId)
|
|
2675
|
+
continue;
|
|
2676
|
+
let parsed;
|
|
2677
|
+
try {
|
|
2678
|
+
parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8'));
|
|
2679
|
+
}
|
|
2680
|
+
catch (error) {
|
|
2681
|
+
if (error.code === 'ENOENT')
|
|
2682
|
+
continue;
|
|
2683
|
+
return null;
|
|
2684
|
+
}
|
|
2685
|
+
if (!parsed || typeof parsed !== 'object')
|
|
2686
|
+
return null;
|
|
2687
|
+
const record = parsed;
|
|
2688
|
+
if (typeof record.session_id !== 'string')
|
|
2689
|
+
return null;
|
|
2690
|
+
if (record.governed !== true)
|
|
2691
|
+
continue;
|
|
2692
|
+
const harnessId = typeof record.harness === 'string' && record.harness ? record.harness : defaultHarness.id;
|
|
2693
|
+
let sharesDescriptor = false;
|
|
2694
|
+
try {
|
|
2695
|
+
sharesDescriptor = (harnessById(harnessId).sharedRuntimes?.(runtimeDir) ?? []).some((descriptor) => descriptor.key === descriptorKey);
|
|
2696
|
+
}
|
|
2697
|
+
catch {
|
|
2698
|
+
return null;
|
|
2699
|
+
}
|
|
2700
|
+
if (sharesDescriptor && record.harness_session_id === threadId)
|
|
2701
|
+
owners.push(record.session_id);
|
|
2702
|
+
}
|
|
2703
|
+
return owners;
|
|
2704
|
+
};
|
|
2705
|
+
async function codexHeadlessReadinessProof(current) {
|
|
2706
|
+
const record = current();
|
|
2707
|
+
if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId)
|
|
2708
|
+
return null;
|
|
2709
|
+
const endpoint = codexEndpointForRecord(record, record.runtimeDir);
|
|
2710
|
+
if (!endpoint)
|
|
2711
|
+
return null;
|
|
2712
|
+
const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
|
|
2713
|
+
.find((candidate) => candidate.key === codexDescriptorKey(endpoint));
|
|
2714
|
+
if (!descriptor?.residency)
|
|
2715
|
+
return null;
|
|
2716
|
+
const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint);
|
|
2717
|
+
if (!generationBefore)
|
|
2718
|
+
return null;
|
|
2719
|
+
let resident;
|
|
2720
|
+
try {
|
|
2721
|
+
resident = await descriptor.residency();
|
|
2722
|
+
}
|
|
2723
|
+
catch {
|
|
2724
|
+
return null;
|
|
2725
|
+
}
|
|
2726
|
+
if (!resident.healthy)
|
|
2727
|
+
return null;
|
|
2728
|
+
if (!resident.referenceIds.includes(record.harnessSessionId))
|
|
2729
|
+
return null;
|
|
2730
|
+
const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId);
|
|
2731
|
+
if (!owners || owners.length !== 1 || owners[0] !== record.session)
|
|
2732
|
+
return null;
|
|
2733
|
+
const generationAfter = codexRuntimeGenerationProof(record.runtimeDir, endpoint);
|
|
2734
|
+
if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter))
|
|
2735
|
+
return null;
|
|
2736
|
+
return Object.freeze({
|
|
2737
|
+
kind: 'codex-headless-shared-runtime',
|
|
2738
|
+
descriptorKey: descriptor.key,
|
|
2739
|
+
generation: generationAfter,
|
|
2740
|
+
target: Object.freeze({
|
|
2741
|
+
sessionId: record.session,
|
|
2742
|
+
threadId: record.harnessSessionId,
|
|
2743
|
+
ownerSessionId: owners[0],
|
|
2744
|
+
ownerCount: 1,
|
|
2745
|
+
ownerState: 'governed',
|
|
2746
|
+
referenceState: 'loaded',
|
|
2747
|
+
protectsControlPlane: true,
|
|
2748
|
+
}),
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2751
|
+
// Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
|
|
2752
|
+
// while launch only runs the backend-owned thread/start + first turn. There is no TUI to attach after that turn;
|
|
2753
|
+
// the shared project app-server keeps the thread addressable and idle sends use the inherited JSON-RPC channel.
|
|
2754
|
+
export const codexHeadlessHarness = {
|
|
2755
|
+
...codexHarness,
|
|
2756
|
+
id: 'codex-headless',
|
|
2757
|
+
sessionEnvVar: harnessIdentity('codex-headless').sessionEnvVar,
|
|
2758
|
+
headless: true,
|
|
2759
|
+
runtimeOwnership: 'adapter',
|
|
2760
|
+
launchOneShot: true,
|
|
2761
|
+
launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
|
|
2762
|
+
// Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
|
|
2763
|
+
// offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
|
|
2764
|
+
liveness: recordOnline,
|
|
2765
|
+
launchReady: async (current, deadline) => {
|
|
2766
|
+
for (;;) {
|
|
2767
|
+
const proof = await codexHeadlessReadinessProof(current);
|
|
2768
|
+
if (proof)
|
|
2769
|
+
return {
|
|
2770
|
+
proof,
|
|
2771
|
+
validate: async (latest) => {
|
|
2772
|
+
const currentProof = await codexHeadlessReadinessProof(latest);
|
|
2773
|
+
return !!currentProof && sameCodexHeadlessReadinessProof(proof, currentProof);
|
|
2774
|
+
},
|
|
2775
|
+
};
|
|
2776
|
+
const remaining = deadline - Date.now();
|
|
2777
|
+
if (remaining <= 0)
|
|
2778
|
+
return null;
|
|
2779
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)));
|
|
2780
|
+
}
|
|
2781
|
+
},
|
|
2782
|
+
// There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
|
|
2783
|
+
// runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
|
|
2784
|
+
resumeArg: () => '',
|
|
2785
|
+
};
|
|
2786
|
+
// @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
|
|
2787
|
+
// the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
|
|
2788
|
+
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
2789
|
+
// hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
|
|
2790
|
+
// extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
|
|
2791
|
+
// dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
|
|
2792
|
+
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
|
|
2793
|
+
// deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
|
|
2794
|
+
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
2795
|
+
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
2796
|
+
// one-run defence. See pi-harness.ts for the extension source + trust mechanics.
|
|
2797
|
+
export const piHarness = {
|
|
2798
|
+
id: 'pi',
|
|
2799
|
+
dispatchId: 'pi',
|
|
2800
|
+
headless: false,
|
|
2801
|
+
events: PI_EVENTS,
|
|
2802
|
+
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
2803
|
+
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
2804
|
+
executionTrace: readSessionJsonlExecutionTrace,
|
|
2805
|
+
launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
|
|
2806
|
+
baseCmd: piBaseCmd,
|
|
2807
|
+
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
2808
|
+
sessionEnvVar: harnessIdentity('pi').sessionEnvVar, // exported by the generated extension at session_start; tool subprocesses inherit it
|
|
2809
|
+
launchEnv: rendezvousLaunchEnv,
|
|
2810
|
+
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
2811
|
+
shimScope: 'tree',
|
|
2812
|
+
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
2813
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
2814
|
+
skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
|
|
2815
|
+
agentDir: () => null, // pi has no file-discovered sub-agent primitive — materialize skips it
|
|
2816
|
+
shim: (dispatch, spex) => ({
|
|
2817
|
+
content: piExtensionSource(dispatch, spex),
|
|
2818
|
+
cmd: (e) => `SPEX='${spex}' bash ${dispatch} pi ${e}`, // what the extension actually spawns, for parity with buildShim
|
|
2819
|
+
}),
|
|
2820
|
+
writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
|
|
2821
|
+
removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
|
|
2822
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject); },
|
|
2823
|
+
slashCommands: piSlashCommands,
|
|
2824
|
+
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
2825
|
+
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
2826
|
+
liveness: socketListenerLiveness,
|
|
2827
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
2828
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
|
|
2829
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2830
|
+
coldRuntime: async () => ({ ok: true }),
|
|
2831
|
+
// reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
|
|
2832
|
+
// LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
|
|
2833
|
+
resumeArg: (rec) => `--session ${rec.session}`,
|
|
2834
|
+
};
|
|
2835
|
+
// pi-headless is an independent harness: its materialization surface is literally pi's, while a resident
|
|
2836
|
+
// controller owns non-interactive text-mode turns. Active turns steer through pi's rendezvous extension;
|
|
2837
|
+
// idle delivery cold-wakes the exact saved session with `--session` (never `--session-id`, which would create a
|
|
2838
|
+
// new conversation). The controller deliberately reports record-backed liveness, matching Claude headless.
|
|
2839
|
+
export const piHeadlessHarness = {
|
|
2840
|
+
...piHarness,
|
|
2841
|
+
id: 'pi-headless',
|
|
2842
|
+
sessionEnvVar: harnessIdentity('pi-headless').sessionEnvVar,
|
|
2843
|
+
headless: true,
|
|
2844
|
+
// The controller is a per-session process launched in the target tmux pane. Its launch-registered PID
|
|
2845
|
+
// and argv session id are exact leaf ownership evidence; record-backed liveness does not make it shared.
|
|
2846
|
+
runtimeOwnership: 'leaf',
|
|
2847
|
+
paneTitleIsSelfSummary: false,
|
|
2848
|
+
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
2849
|
+
liveness: recordOnline,
|
|
2850
|
+
deliver: deliverViaPiHeadless,
|
|
2851
|
+
cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
|
|
2852
|
+
coldRuntime: async (rec) => {
|
|
2853
|
+
const result = await piHeadlessColdRuntime(rec);
|
|
2854
|
+
return result.ok ? { ok: true } : { ok: false, reason: result.error || 'pi-headless runtime remains unproven' };
|
|
2855
|
+
},
|
|
2856
|
+
deliveryBlockedBy: undefined,
|
|
2857
|
+
resumeArg: (rec) => `--session ${rec.session}`,
|
|
2858
|
+
};
|
|
2859
|
+
const ZCODE_CONTROL_UNAVAILABLE = 'zcode has no control channel; start a new session instead of delivering to an existing one';
|
|
2860
|
+
// z-code's app-server is stdin/stdout NDJSON, unlike Codex's Unix-socket WebSocket + thread RPC. This row
|
|
2861
|
+
// intentionally covers the one-turn `--prompt` launcher and Claude-compatible hooks only; control operations
|
|
2862
|
+
// refuse rather than pretending the incompatible protocol accepted them.
|
|
2863
|
+
export const zcodeHarness = {
|
|
2864
|
+
id: 'zcode',
|
|
2865
|
+
dispatchId: 'zcode',
|
|
2866
|
+
headless: true,
|
|
2867
|
+
launchOneShot: true,
|
|
2868
|
+
events: ZCODE_EVENTS,
|
|
2869
|
+
ownsRendezvous: false,
|
|
2870
|
+
paneTitleIsSelfSummary: false,
|
|
2871
|
+
executionTrace: noExecutionTrace,
|
|
2872
|
+
launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
|
|
2873
|
+
baseCmd: zcodeBaseCmd,
|
|
2874
|
+
// z-code's one-turn launcher already IS the non-interactive shape; it takes the prompt as an argument.
|
|
2875
|
+
oneShotTurn: (prompt, cmd) => ({ command: `${zcodeBaseCmd(cmd)} --prompt ${shQuote(prompt)}`, stdin: '' }),
|
|
2876
|
+
sessionIdArg: () => '',
|
|
2877
|
+
sessionEnvVar: harnessIdentity('zcode').sessionEnvVar,
|
|
2878
|
+
launchEnv: noLaunchEnv,
|
|
2879
|
+
shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
|
|
2880
|
+
shimScope: 'tree',
|
|
2881
|
+
worktreeHookAnchor: () => null,
|
|
2882
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')],
|
|
2883
|
+
skillDir: (proj) => join(proj, '.zcode', 'skills'),
|
|
2884
|
+
agentDir: (proj) => join(proj, '.zcode', 'agents'),
|
|
2885
|
+
shim: (dispatch, spex) => buildShim('zcode', ZCODE_EVENTS, dispatch, spex),
|
|
2886
|
+
writeTrust: () => [],
|
|
2887
|
+
removeTrust: () => { },
|
|
2888
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject); },
|
|
2889
|
+
slashCommands: () => [],
|
|
2890
|
+
liveness: panePidLiveness,
|
|
2891
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
2892
|
+
deliver: async () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE); },
|
|
2893
|
+
cleanupRuntime: async () => { },
|
|
2894
|
+
coldRuntime: async () => ({ ok: true }),
|
|
2895
|
+
resumeArg: () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE); },
|
|
2896
|
+
};
|
|
2897
|
+
export const opencodeHarness = {
|
|
2898
|
+
id: 'opencode',
|
|
2899
|
+
dispatchId: 'opencode',
|
|
2900
|
+
headless: false,
|
|
2901
|
+
events: OPENCODE_EVENTS,
|
|
2902
|
+
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
2903
|
+
// socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
|
|
2904
|
+
ownsRendezvous: true,
|
|
2905
|
+
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
2906
|
+
executionTrace: readLocalStoreExecutionTrace,
|
|
2907
|
+
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
2908
|
+
baseCmd: opencodeBaseCmd,
|
|
2909
|
+
// `opencode run` takes the message positionally; it documents no stdin form, so the prompt is an argument.
|
|
2910
|
+
oneShotTurn: (prompt, cmd) => ({ command: `${opencodeBaseCmd(cmd)} run ${shQuote(prompt)}`, stdin: '' }),
|
|
2911
|
+
sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
|
|
2912
|
+
// opencode exports NO per-session env var to its tool subprocesses (probed, 1.18.3). Identity flows through
|
|
2913
|
+
// the launch-injected SPEXCODE_SESSION_ID — honest here because each opencode TUI is a per-session process
|
|
2914
|
+
// (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
|
|
2915
|
+
// SPEXCODE_SESSION_ID tier resolves the record.
|
|
2916
|
+
sessionEnvVar: harnessIdentity('opencode').sessionEnvVar,
|
|
2917
|
+
launchEnv: rendezvousLaunchEnv,
|
|
2918
|
+
// the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
|
|
2919
|
+
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
2920
|
+
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
2921
|
+
shimScope: 'tree',
|
|
2922
|
+
worktreeHookAnchor: () => null,
|
|
2923
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
|
|
2924
|
+
skillDir: (proj) => join(proj, '.opencode', 'skills'),
|
|
2925
|
+
agentDir: (proj) => join(proj, '.opencode', 'agents'),
|
|
2926
|
+
// content = the plugin source; cmd = the SAME per-event command the plugin bakes into dispatch calls, so
|
|
2927
|
+
// any consumer that hashes/inspects commands sees one truth (trust is a no-op here regardless).
|
|
2928
|
+
shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
|
|
2929
|
+
writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
|
|
2930
|
+
removeTrust: () => { },
|
|
2931
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject); },
|
|
2932
|
+
slashCommands: opencodeSlashCommands,
|
|
2933
|
+
// online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
|
|
2934
|
+
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|
|
2935
|
+
// load still reads honestly from the process signal instead of a false offline.
|
|
2936
|
+
liveness: socketListenerOrPidAliveLiveness,
|
|
2937
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
2938
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
|
|
2939
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2940
|
+
coldRuntime: async () => ({ ok: true }),
|
|
2941
|
+
// owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
|
|
2942
|
+
// SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
|
|
2943
|
+
// which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
|
|
2944
|
+
// is: a NEW launch's tail is always ONE single-quoted prompt arg, never a literal marker.
|
|
2945
|
+
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
|
|
2946
|
+
};
|
|
2947
|
+
// OpenCode headless is a separate harness, not an opencode mode. Its materialize half is exactly
|
|
2948
|
+
// opencodeHarness; only the one-turn runtime and its capability row differ.
|
|
2949
|
+
export const opencodeHeadlessHarness = {
|
|
2950
|
+
...opencodeHarness,
|
|
2951
|
+
id: 'opencode-headless',
|
|
2952
|
+
sessionEnvVar: harnessIdentity('opencode-headless').sessionEnvVar,
|
|
2953
|
+
headless: true,
|
|
2954
|
+
runtimeOwnership: 'adapter',
|
|
2955
|
+
launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
|
|
2956
|
+
// A sleeping native conversation is still addressable by its non-stopped record. Transport breakage belongs
|
|
2957
|
+
// to the next delivery, where the live rendezvous or pane wake reports it loudly.
|
|
2958
|
+
liveness: recordOnline,
|
|
2959
|
+
coldRuntime: async () => ({ ok: false, reason: 'opencode-headless has no exact resident unload verification' }),
|
|
2960
|
+
deliver: async (rec, text) => {
|
|
2961
|
+
return deliverViaSocketOrWake(rec.session, text, rec.mid, () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)), `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`);
|
|
2962
|
+
},
|
|
2963
|
+
};
|
|
2964
|
+
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
2965
|
+
export const HARNESSES = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness];
|
|
2966
|
+
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
2967
|
+
export const defaultHarness = claudeHarness;
|
|
2968
|
+
// the registry lookup as DATA. A sweep over records nobody is currently asking about (a removed plugin, a
|
|
2969
|
+
// renamed id) must report an unresolvable harness rather than abort, so it resolves through this.
|
|
2970
|
+
export function harnessByIdOrNull(id) {
|
|
2971
|
+
return HARNESSES.find((x) => x.id === id) ?? null;
|
|
2972
|
+
}
|
|
2973
|
+
// resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
|
|
2974
|
+
export function harnessById(id) {
|
|
2975
|
+
const h = harnessByIdOrNull(id);
|
|
2976
|
+
if (!h)
|
|
2977
|
+
throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`);
|
|
2978
|
+
return h;
|
|
2979
|
+
}
|
|
2980
|
+
// the complete configured named launchers from spexcode.json, as a stable name-sorted list (for CLI/session
|
|
2981
|
+
// resolution and downstream projections). Picking a launcher is the ONLY launch choice; the old separate
|
|
2982
|
+
// harness pick is gone.
|
|
2983
|
+
export function launcherList(root = mainCheckout()) {
|
|
2984
|
+
const m = readConfig(root).sessions?.launchers || {};
|
|
2985
|
+
return Object.keys(m)
|
|
2986
|
+
.map((name) => {
|
|
2987
|
+
const harness = harnessById(m[name].harness || defaultHarness.id);
|
|
2988
|
+
return { name, harness: harness.id, cmd: m[name].cmd, headless: harness.headless };
|
|
2989
|
+
})
|
|
2990
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2991
|
+
}
|
|
2992
|
+
// The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
|
|
2993
|
+
// it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
|
|
2994
|
+
export function dashboardLauncherList(root = mainCheckout()) {
|
|
2995
|
+
const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true;
|
|
2996
|
+
return launcherList(root).filter((launcher) => showHeadless || !launcher.headless);
|
|
2997
|
+
}
|
|
2998
|
+
export const MISSING_DEFAULT_LAUNCHER_ERROR = 'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})';
|
|
2999
|
+
// the configured default launcher NAME ([[launcher-select]]) — the profile `spex session new`/a dropdown pick with no
|
|
3000
|
+
// explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to a
|
|
3001
|
+
// `claude` launcher (which `spex init` seeds by name, so a default can point at it explicitly).
|
|
3002
|
+
export function defaultLauncher(root = mainCheckout()) {
|
|
3003
|
+
const name = readConfig(root).sessions?.defaultLauncher?.trim();
|
|
3004
|
+
if (!name)
|
|
3005
|
+
throw new Error(MISSING_DEFAULT_LAUNCHER_ERROR);
|
|
3006
|
+
return name;
|
|
3007
|
+
}
|
|
3008
|
+
export function launcherDefault(root = mainCheckout()) {
|
|
3009
|
+
try {
|
|
3010
|
+
const name = defaultLauncher(root);
|
|
3011
|
+
resolveLauncher(name, root);
|
|
3012
|
+
return { default: name, error: null };
|
|
3013
|
+
}
|
|
3014
|
+
catch (e) {
|
|
3015
|
+
return { default: null, error: String(e.message || e) };
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
export function resolveLauncher(name, root = mainCheckout()) {
|
|
3019
|
+
const l = readConfig(root).sessions?.launchers?.[name];
|
|
3020
|
+
if (!l)
|
|
3021
|
+
throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`);
|
|
3022
|
+
if (!l.cmd)
|
|
3023
|
+
throw new Error(`launcher '${name}' is missing cmd`);
|
|
3024
|
+
const harness = harnessById(l.harness || defaultHarness.id); // validate the harness id fail-loud
|
|
3025
|
+
return { name, harness: harness.id, cmd: l.cmd, headless: harness.headless };
|
|
3026
|
+
}
|