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