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/sessions.ts
DELETED
|
@@ -1,3403 +0,0 @@
|
|
|
1
|
-
import { execFile, spawn } from 'node:child_process'
|
|
2
|
-
import { promisify } from 'node:util'
|
|
3
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
-
import { createRequire } from 'node:module'
|
|
5
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, openSync, closeSync, unlinkSync, writeSync } from 'node:fs'
|
|
6
|
-
import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
|
|
7
|
-
import { fileURLToPath } from 'node:url'
|
|
8
|
-
import { seedWorktreeHostState } from './worktree-sources.js'
|
|
9
|
-
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, type ReviewDiffFile } from './git.js'
|
|
10
|
-
import { loadConfig, loadSpecs, loadSpecsLite, type ConfigPreset, type SpecLite } from './specs.js'
|
|
11
|
-
import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type TurnFailure, type FailureSubscription, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
12
|
-
import { materialize } from './materialize.js'
|
|
13
|
-
import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
|
|
14
|
-
import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
15
|
-
import { drain, enqueue, owesDelivery } from './delivery-queue.js'
|
|
16
|
-
import { stripRefSigil } from './mentions.js'
|
|
17
|
-
import { shQuote } from './sh.js'
|
|
18
|
-
import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
|
|
19
|
-
import { processStartToken } from './process-identity.js'
|
|
20
|
-
import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js'
|
|
21
|
-
|
|
22
|
-
const pexec = promisify(execFile)
|
|
23
|
-
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
24
|
-
const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' }
|
|
25
|
-
const HARNESS = defaultHarness
|
|
26
|
-
const COLS = 120, ROWS = 32
|
|
27
|
-
const DEFAULT_MAX_ACTIVE = 8
|
|
28
|
-
function maxActive(): number {
|
|
29
|
-
let v: number | undefined
|
|
30
|
-
try {
|
|
31
|
-
const fromJson = readConfig(mainRoot()).sessions?.maxActive
|
|
32
|
-
if (typeof fromJson === 'number' && Number.isFinite(fromJson)) v = fromJson
|
|
33
|
-
} catch { /* config unreadable — fall through to env/default */ }
|
|
34
|
-
if (v === undefined) { const e = Number(process.env.SPEXCODE_MAX_ACTIVE); if (Number.isFinite(e) && e > 0) v = e }
|
|
35
|
-
return Math.max(1, Math.floor(v ?? DEFAULT_MAX_ACTIVE))
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// The adapter owns any transport bootstrap env (rendezvous daemon + socket); product launch only composes it
|
|
39
|
-
// with the governed session id and home vars. This env prefix also ensures hooks + materialize write to the
|
|
40
|
-
// SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
|
|
41
|
-
// propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
|
|
42
|
-
// an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
|
|
43
|
-
// ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
|
|
44
|
-
const rvEnv = (id: string, harness = HARNESS) => {
|
|
45
|
-
// SPEXCODE_SESSION_ID is the governed record id, and it is the SESSION'S OWN — so the launch STRIPS every
|
|
46
|
-
// session-identity variable it may have inherited (the pane inherits the tmux SERVER's env, which may carry
|
|
47
|
-
// a foreign session's ids from whoever started it) before setting this one. Identity is established HERE,
|
|
48
|
-
// once, at the boundary; nothing downstream re-verifies it, because after this a session-identity variable
|
|
49
|
-
// exists in a process only if that process belongs to that session — either set right here, or stamped by
|
|
50
|
-
// the harness itself for its own acting conversation ([[harness-adapter]]). The same strip runs on the one
|
|
51
|
-
// other process we own that is NOT a session's own — codex's shared app-server, whose leaked inherited id
|
|
52
|
-
// was github#76.
|
|
53
|
-
const scrub = sessionIdentityEnvVars().map((v) => `-u ${v}`)
|
|
54
|
-
const homeVars = ['SPEXCODE_HOME', 'CODEX_HOME'].flatMap((v) => {
|
|
55
|
-
const value = process.env[v]
|
|
56
|
-
return value ? [`${v}=${value}`] : []
|
|
57
|
-
})
|
|
58
|
-
return [...scrub,
|
|
59
|
-
`SPEXCODE_SESSION_ID=${id}`,
|
|
60
|
-
`SPEXCODE_SESSION_IDENTITY_VARS=${shQuote(sessionIdentityEnvVars().join(','))}`,
|
|
61
|
-
`SPEXCODE_PROJECT_ROOT=${shQuote(mainRoot())}`,
|
|
62
|
-
...harness.launchEnv(id), ...homeVars].join(' ')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Re-exported for existing importers.
|
|
66
|
-
export type { DispatchResult }
|
|
67
|
-
|
|
68
|
-
export type Lifecycle = SessionLifecycle
|
|
69
|
-
export type Proposal = SessionProposal
|
|
70
|
-
export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown' | 'corrupt' | 'retired'
|
|
71
|
-
export type Liveness = 'online' | 'starting' | 'offline' | 'unknown'
|
|
72
|
-
const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
|
|
73
|
-
|
|
74
|
-
export type Session = {
|
|
75
|
-
id: string; node: string | null; branch: string | null; path: string
|
|
76
|
-
label: string; title: string // `label` remains the stable search handle; `title` is the one visible session name
|
|
77
|
-
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
78
|
-
parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
|
|
79
|
-
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
80
|
-
capabilities: { headless: boolean } // stable adapter projection; console surfaces consume data, never harness ids
|
|
81
|
-
launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
|
|
82
|
-
lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
|
|
83
|
-
archived: boolean // cold storage ([[archive]]) — successful records are offline; default views exclude them
|
|
84
|
-
archiveHazard?: string | null // explicit legacy/invariant violation; never hidden as a clean archive
|
|
85
|
-
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
86
|
-
sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
|
|
90
|
-
|
|
91
|
-
function writePromptFile(id: string, prompt: string): void {
|
|
92
|
-
try { writeFileSync(join(storeDir(id), 'prompt'), prompt) } catch { /* best-effort; must never block the launch */ }
|
|
93
|
-
}
|
|
94
|
-
function readPromptFile(id: string): string | null {
|
|
95
|
-
try {
|
|
96
|
-
const p = sessionArtifactPath(id, 'prompt')
|
|
97
|
-
if (!existsSync(p)) return null
|
|
98
|
-
const s = readFileSync(p, 'utf8')
|
|
99
|
-
return s.trim() ? s : null
|
|
100
|
-
} catch { return null }
|
|
101
|
-
}
|
|
102
|
-
// Persist queued launch input across restarts; consume it once the launch begins.
|
|
103
|
-
function writeLaunchFile(id: string, prompt: string): void {
|
|
104
|
-
try { writeFileSync(join(storeDir(id), 'launch'), prompt) } catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
|
|
105
|
-
}
|
|
106
|
-
function readLaunchFile(id: string): string | null {
|
|
107
|
-
try { const p = sessionArtifactPath(id, 'launch'); return existsSync(p) ? readFileSync(p, 'utf8') : null } catch { return null }
|
|
108
|
-
}
|
|
109
|
-
function removeLaunchFile(id: string): void {
|
|
110
|
-
try { rmSync(sessionArtifactPath(id, 'launch'), { force: true }) } catch { /* best-effort */ }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// One line, bounded — the launch prompt's shape when it enters a compact headline.
|
|
114
|
-
export const HEADLINE_PREVIEW_COLUMNS = 60
|
|
115
|
-
function isBareUrl(text: string): boolean {
|
|
116
|
-
return /^(?:https?|git|ssh):\/\/\S+$/i.test(text)
|
|
117
|
-
}
|
|
118
|
-
function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
|
|
119
|
-
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
120
|
-
const first = lines.find((line) => !isBareUrl(line)) || lines[0] || ''
|
|
121
|
-
return first.length > n ? first.slice(0, n - 1) + '…' : first
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
125
|
-
r.name || r.node || r.title || r.branch || r.id
|
|
126
|
-
export const deriveTitle = (r: { name?: string | null; activity?: string | null; note?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
127
|
-
r.name || r.activity || (r.note ? oneLinePreview(r.note) : '') || (r.promptPreview ? oneLinePreview(r.promptPreview) : '') || r.node || r.title || r.branch || r.id
|
|
128
|
-
// Compatibility for package consumers that still import the old name.
|
|
129
|
-
export const deriveHeadline = deriveTitle
|
|
130
|
-
|
|
131
|
-
export const sessionLabel = (s: Session): string => s.label
|
|
132
|
-
export const sessionTitle = (s: Session): string => s.title
|
|
133
|
-
// Compatibility for older callers; all visible surfaces now resolve through `title`.
|
|
134
|
-
export const sessionHeadline = sessionTitle
|
|
135
|
-
|
|
136
|
-
// @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
|
|
137
|
-
// HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
|
|
138
|
-
// the human read as "sessions disappeared". So the liveness/title probes pass a bounded timeout; on expiry
|
|
139
|
-
// execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
|
|
140
|
-
// "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
|
|
141
|
-
const TMUX_PROBE_TIMEOUT_MS = 4000
|
|
142
|
-
async function tmux(args: string[], timeoutMs?: number): Promise<string> {
|
|
143
|
-
const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' as const } : {}) })
|
|
144
|
-
return stdout
|
|
145
|
-
}
|
|
146
|
-
// a rejected pexec whose child we KILLED (timeout) vs one that exited cleanly non-zero (e.g. tmux "no server
|
|
147
|
-
// running" when there are genuinely no sessions). Only the former is a PROBE FAILURE (→ unknown); a clean
|
|
148
|
-
// non-zero exit is authoritative (→ everything offline). node sets `killed`/`signal` when it SIGKILLs on timeout.
|
|
149
|
-
function probeTimedOut(e: unknown): boolean {
|
|
150
|
-
const err = e as { killed?: boolean; signal?: string | null; code?: string }
|
|
151
|
-
return err?.killed === true || err?.signal === 'SIGKILL' || err?.code === 'ETIMEDOUT'
|
|
152
|
-
}
|
|
153
|
-
async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
|
|
154
|
-
export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
|
|
155
|
-
|
|
156
|
-
function pkgRoot(): string {
|
|
157
|
-
return fileURLToPath(new URL('..', import.meta.url))
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export type SessRec = {
|
|
161
|
-
session: string; governed: boolean; worktreePath: string; branch: string | null
|
|
162
|
-
node: string | null; title: string | null; name: string | null
|
|
163
|
-
parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
|
|
164
|
-
status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
|
|
165
|
-
sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
|
|
166
|
-
stopped: boolean // explicit human stop; liveness metadata, never an agent-authored lifecycle value
|
|
167
|
-
archived: boolean // shelved by the human ([[archive]]) — only clean after coldProof is written
|
|
168
|
-
coldProof?: string | null // durable exact leaf + adapter unload proof; missing on legacy archives => visible hazard
|
|
169
|
-
adapterRecovery?: string | null // explicit adapter recovery state after an uncertain partial cold mutation
|
|
170
|
-
launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
|
|
171
|
-
launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
|
|
172
|
-
launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
|
|
173
|
-
createRequestId?: string | null // digest of the public Idempotency-Key; binds retry without storing the bearer
|
|
174
|
-
createPayloadHash?: string | null // exact normalized create payload bound to createRequestId
|
|
175
|
-
launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
|
|
176
|
-
}
|
|
177
|
-
type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
|
|
178
|
-
type LaunchReadinessPending = { version: 1; startedAt: number; original: LaunchReadinessOriginal }
|
|
179
|
-
export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
|
|
180
|
-
|
|
181
|
-
export function backendLaunchAuthority(env: { SPEXCODE_API_URL?: string; PORT?: string } = process.env): string {
|
|
182
|
-
const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`
|
|
183
|
-
const url = new URL(raw)
|
|
184
|
-
url.username = ''
|
|
185
|
-
url.password = ''
|
|
186
|
-
url.search = ''
|
|
187
|
-
url.hash = ''
|
|
188
|
-
url.pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
189
|
-
return url.toString().replace(/\/$/, '')
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function rawLifecycleStatus(rec: Pick<SessRec, 'status' | 'launchOwner'>): string {
|
|
193
|
-
return rec.status === 'queued' && rec.launchOwner ? OWNED_QUEUE_RAW_STATUS : rec.status
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner'>, authority = backendLaunchAuthority()): boolean {
|
|
197
|
-
return rec.status === 'queued' && (rec.launchOwner === null || rec.launchOwner === authority)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// typed read of a session's record from the global store (null if it has none — a self-launched session that
|
|
201
|
-
// only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
|
|
202
|
-
// readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
|
|
203
|
-
// on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
|
|
204
|
-
function readRecord(id: string): SessRec | null {
|
|
205
|
-
const entry = readAliasedRecordEntry(id)
|
|
206
|
-
if (entry.kind === 'absent') return null
|
|
207
|
-
if (entry.kind === 'corrupt') throw new SessionRecordUnusable('corrupt', id, corruptReason(entry))
|
|
208
|
-
try { return fromRaw(entry.raw) }
|
|
209
|
-
catch (error) {
|
|
210
|
-
throw new SessionRecordUnusable('corrupt', id,
|
|
211
|
-
`session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
export class SessionRecordUnusable extends Error {
|
|
215
|
-
constructor(readonly code: 'corrupt' | 'retired', readonly session: string, message: string) {
|
|
216
|
-
super(message)
|
|
217
|
-
this.name = 'SessionRecordUnusable'
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const corruptReason = (e: { path: string; error: string }): string =>
|
|
221
|
-
`session record is unreadable: ${e.path} — ${e.error}. The file is kept as-is; nothing will rewrite it. A close attempt quarantines the bytes and reports the preserved runtime/worktree/branch residue, but cannot signal or delete without an exact owner.`
|
|
222
|
-
function retirementReason(rec: SessRec): string | null {
|
|
223
|
-
if (!rec.worktreePath || existsSync(rec.worktreePath)) return null
|
|
224
|
-
return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`
|
|
225
|
-
}
|
|
226
|
-
function readLiveRecord(id: string): SessRec | null {
|
|
227
|
-
const rec = readRecord(id)
|
|
228
|
-
if (!rec) return null
|
|
229
|
-
const retired = retirementReason(rec)
|
|
230
|
-
if (retired) throw new SessionRecordUnusable('retired', rec.session, retired)
|
|
231
|
-
return rec
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Cross-process lifecycle serialization. Hooks and operator commands are separate CLI processes, so the
|
|
235
|
-
// in-memory transition tail is only an optimization. This lock covers each read/modify/write or destructive
|
|
236
|
-
// transition across archive/resume/stop/close and hook writers. It lives outside the record directory so close
|
|
237
|
-
// may remove the record while its lock is held. A dead writer's lock is reclaimed; a live writer is waited for
|
|
238
|
-
// with a bounded wall and then fails loudly rather than allowing a stale write to win.
|
|
239
|
-
const recordLockRoot = () => join(runtimeRoot(), '.session-locks')
|
|
240
|
-
const recordLockPath = (id: string) => join(recordLockRoot(), `${id}.lock`)
|
|
241
|
-
const syncPause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
242
|
-
function acquireRecordLockSync(id: string, timeoutMs = 30_000): () => void {
|
|
243
|
-
mkdirSync(recordLockRoot(), { recursive: true })
|
|
244
|
-
const path = recordLockPath(id), deadline = Date.now() + timeoutMs
|
|
245
|
-
for (;;) {
|
|
246
|
-
try {
|
|
247
|
-
const fd = openSync(path, 'wx')
|
|
248
|
-
writeSync(fd, String(process.pid))
|
|
249
|
-
closeSync(fd)
|
|
250
|
-
return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
|
|
251
|
-
} catch (e) {
|
|
252
|
-
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
253
|
-
let owner = 0
|
|
254
|
-
try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
|
|
255
|
-
if (owner && owner !== process.pid) {
|
|
256
|
-
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
257
|
-
}
|
|
258
|
-
if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
|
|
259
|
-
syncPause(10)
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
const abortedOperation = (signal: AbortSignal): Error => signal.reason instanceof Error
|
|
264
|
-
? signal.reason
|
|
265
|
-
: Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' })
|
|
266
|
-
async function recordLockPause(signal?: AbortSignal): Promise<void> {
|
|
267
|
-
if (!signal) { await new Promise((resolve) => setTimeout(resolve, 10)); return }
|
|
268
|
-
if (signal.aborted) throw abortedOperation(signal)
|
|
269
|
-
await new Promise<void>((resolve, reject) => {
|
|
270
|
-
const timer = setTimeout(done, 10)
|
|
271
|
-
const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(abortedOperation(signal)) }
|
|
272
|
-
function done() { signal!.removeEventListener('abort', abort); resolve() }
|
|
273
|
-
signal.addEventListener('abort', abort, { once: true })
|
|
274
|
-
})
|
|
275
|
-
}
|
|
276
|
-
async function acquireRecordLock(id: string, timeoutMs = 30_000, signal?: AbortSignal): Promise<() => void> {
|
|
277
|
-
mkdirSync(recordLockRoot(), { recursive: true })
|
|
278
|
-
const path = recordLockPath(id), deadline = Date.now() + timeoutMs
|
|
279
|
-
for (;;) {
|
|
280
|
-
if (signal?.aborted) throw abortedOperation(signal)
|
|
281
|
-
try {
|
|
282
|
-
const fd = openSync(path, 'wx')
|
|
283
|
-
writeSync(fd, String(process.pid))
|
|
284
|
-
closeSync(fd)
|
|
285
|
-
return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
|
|
286
|
-
} catch (e) {
|
|
287
|
-
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
288
|
-
let owner = 0
|
|
289
|
-
try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
|
|
290
|
-
if (owner && owner !== process.pid) {
|
|
291
|
-
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
292
|
-
}
|
|
293
|
-
if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
|
|
294
|
-
await recordLockPause(signal)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
async function withRecordLock<T>(id: string, body: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
299
|
-
const release = await acquireRecordLock(id, 30_000, signal)
|
|
300
|
-
try { return await body() } finally { release() }
|
|
301
|
-
}
|
|
302
|
-
function withRecordLockSync<T>(id: string, body: () => T): T {
|
|
303
|
-
const release = acquireRecordLockSync(id)
|
|
304
|
-
try { return body() } finally { release() }
|
|
305
|
-
}
|
|
306
|
-
function tryRecordLockSync(id: string): (() => void) | null {
|
|
307
|
-
mkdirSync(recordLockRoot(), { recursive: true })
|
|
308
|
-
const path = recordLockPath(id)
|
|
309
|
-
try {
|
|
310
|
-
const fd = openSync(path, 'wx')
|
|
311
|
-
writeSync(fd, String(process.pid))
|
|
312
|
-
closeSync(fd)
|
|
313
|
-
return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
|
|
314
|
-
} catch (e) {
|
|
315
|
-
if ((e as NodeJS.ErrnoException).code === 'EEXIST') return null
|
|
316
|
-
throw e
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
// Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
|
|
320
|
-
// enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
|
|
321
|
-
// and then race a just-queued TUI turn.
|
|
322
|
-
export function withSessionInputLock<T>(id: string, body: () => T): T | null {
|
|
323
|
-
// PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
|
|
324
|
-
// regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
|
|
325
|
-
const release = tryRecordLockSync(id)
|
|
326
|
-
if (!release) return null
|
|
327
|
-
try { return body() } finally { release() }
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const COLD_PROOF_VERSION = 'cold-v1'
|
|
331
|
-
function coldProofFor(rec: Pick<SessRec, 'session' | 'harness' | 'harnessSessionId'>): string {
|
|
332
|
-
const adapter = harnessById(rec.harness || defaultHarness.id).id
|
|
333
|
-
const exact = rec.harnessSessionId ? `thread:${rec.harnessSessionId}` : 'no-resident-ref'
|
|
334
|
-
return `${COLD_PROOF_VERSION}|${adapter}|${rec.session}|${exact}`
|
|
335
|
-
}
|
|
336
|
-
function hasValidColdProof(rec: SessRec): boolean {
|
|
337
|
-
return !!rec.coldProof && rec.coldProof === coldProofFor(rec)
|
|
338
|
-
}
|
|
339
|
-
// the loose on-disk fields validated into the typed shape. Exported so the old-record defaults (harness →
|
|
340
|
-
// claude, absent pin → null) are unit-auditable without a store on disk.
|
|
341
|
-
export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
|
|
342
|
-
const ownedQueue = raw.status === OWNED_QUEUE_RAW_STATUS
|
|
343
|
-
const status = ownedQueue ? 'queued' : isSessionLifecycle(raw.status) ? raw.status : 'active'
|
|
344
|
-
const launchOwner = ownedQueue ? raw.launch_owner?.trim() : null
|
|
345
|
-
if (ownedQueue && !launchOwner) throw new Error(`owned queue record '${raw.session_id}' has no launch_owner`)
|
|
346
|
-
const proposal = isSessionProposal(raw.proposal) ? raw.proposal : null
|
|
347
|
-
const sk = raw.sortkey
|
|
348
|
-
const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
|
|
349
|
-
const pendingRaw = rawLaunchReadinessOriginal(raw)
|
|
350
|
-
const pendingStatus = pendingRaw && isSessionLifecycle(pendingRaw.status) ? pendingRaw.status : null
|
|
351
|
-
if (pendingRaw && !pendingStatus) throw new Error(`session '${raw.session_id}' launch readiness original has invalid lifecycle '${pendingRaw.status}'`)
|
|
352
|
-
const pendingProposal = pendingRaw && isSessionProposal(pendingRaw.proposal) ? pendingRaw.proposal : null
|
|
353
|
-
if (pendingRaw?.proposal && !pendingProposal) throw new Error(`session '${raw.session_id}' launch readiness original has invalid proposal '${pendingRaw.proposal}'`)
|
|
354
|
-
return {
|
|
355
|
-
session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
|
|
356
|
-
node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
|
|
357
|
-
status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
|
|
358
|
-
harness: raw.harness || 'claude', // records written before the harness field default to claude
|
|
359
|
-
harnessSessionId: raw.harness_session_id || null,
|
|
360
|
-
stopped: !!raw.stopped, // records written before explicit stop tracking were not stopped
|
|
361
|
-
archived: !!raw.archived, // records written before archive → absent → not shelved
|
|
362
|
-
coldProof: raw.cold_proof || null, // legacy archived rows have no proof and remain visible until re-archived
|
|
363
|
-
adapterRecovery: raw.adapter_recovery || null,
|
|
364
|
-
launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
|
|
365
|
-
launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
|
|
366
|
-
launchOwner: launchOwner || null,
|
|
367
|
-
createRequestId: raw.create_request_id || null,
|
|
368
|
-
createPayloadHash: raw.create_payload_hash || null,
|
|
369
|
-
launchReadinessPending: pendingRaw ? {
|
|
370
|
-
version: 1,
|
|
371
|
-
startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
|
|
372
|
-
original: {
|
|
373
|
-
status: pendingStatus!, proposal: pendingProposal, note: pendingRaw.note || null,
|
|
374
|
-
stopped: pendingRaw.stopped, archived: pendingRaw.archived,
|
|
375
|
-
coldProof: pendingRaw.cold_proof || null, adapterRecovery: pendingRaw.adapter_recovery || null,
|
|
376
|
-
},
|
|
377
|
-
} : null,
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function publicRecord(rec: SessRec): SessRec {
|
|
382
|
-
const original = rec.launchReadinessPending?.original
|
|
383
|
-
return original ? { ...rec, ...original } : rec
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function launchReadinessPending(original: SessRec): LaunchReadinessPending {
|
|
387
|
-
return {
|
|
388
|
-
version: 1,
|
|
389
|
-
startedAt: Date.now(),
|
|
390
|
-
original: {
|
|
391
|
-
status: original.status,
|
|
392
|
-
proposal: original.proposal,
|
|
393
|
-
note: original.note,
|
|
394
|
-
stopped: original.stopped,
|
|
395
|
-
archived: original.archived,
|
|
396
|
-
coldProof: original.coldProof ?? null,
|
|
397
|
-
adapterRecovery: original.adapterRecovery ?? null,
|
|
398
|
-
},
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function restoreLaunchReadinessOriginal(rec: SessRec): SessRec {
|
|
403
|
-
const original = rec.launchReadinessPending?.original
|
|
404
|
-
return original ? { ...rec, ...original, launchReadinessPending: null } : rec
|
|
405
|
-
}
|
|
406
|
-
// Rebuild the full disk projection so retired keys disappear on the next write.
|
|
407
|
-
function writeRecord(rec: SessRec): void {
|
|
408
|
-
let previous: SessRec | null = null
|
|
409
|
-
try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
|
|
410
|
-
const obj = {
|
|
411
|
-
session_id: rec.session,
|
|
412
|
-
governed: rec.governed,
|
|
413
|
-
worktree_path: rec.worktreePath,
|
|
414
|
-
branch: rec.branch ?? '',
|
|
415
|
-
node: rec.node ?? '',
|
|
416
|
-
title: rec.title ?? '',
|
|
417
|
-
name: rec.name ?? '',
|
|
418
|
-
parent: rec.parent ?? '',
|
|
419
|
-
status: rawLifecycleStatus(rec),
|
|
420
|
-
proposal: rec.proposal ?? '',
|
|
421
|
-
merges: rec.merges,
|
|
422
|
-
note: rec.note ?? '',
|
|
423
|
-
sortkey: rec.sortKey ?? '',
|
|
424
|
-
createdAt: rec.createdAt,
|
|
425
|
-
harness: rec.harness || 'claude',
|
|
426
|
-
harness_session_id: rec.harnessSessionId ?? '',
|
|
427
|
-
stopped: rec.stopped,
|
|
428
|
-
archived: rec.archived,
|
|
429
|
-
cold_proof: rec.coldProof ?? '',
|
|
430
|
-
adapter_recovery: rec.adapterRecovery ?? '',
|
|
431
|
-
launcher: rec.launcher ?? '',
|
|
432
|
-
launch_cmd: rec.launchCmd ?? '',
|
|
433
|
-
launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
|
|
434
|
-
create_request_id: rec.createRequestId ?? '',
|
|
435
|
-
create_payload_hash: rec.createPayloadHash ?? '',
|
|
436
|
-
launch_readiness_pending: rec.launchReadinessPending ? {
|
|
437
|
-
version: 1,
|
|
438
|
-
startedAt: rec.launchReadinessPending.startedAt,
|
|
439
|
-
original: {
|
|
440
|
-
status: rec.launchReadinessPending.original.status,
|
|
441
|
-
proposal: rec.launchReadinessPending.original.proposal ?? '',
|
|
442
|
-
note: rec.launchReadinessPending.original.note ?? '',
|
|
443
|
-
stopped: rec.launchReadinessPending.original.stopped,
|
|
444
|
-
archived: rec.launchReadinessPending.original.archived,
|
|
445
|
-
cold_proof: rec.launchReadinessPending.original.coldProof ?? '',
|
|
446
|
-
adapter_recovery: rec.launchReadinessPending.original.adapterRecovery ?? '',
|
|
447
|
-
},
|
|
448
|
-
} : '',
|
|
449
|
-
}
|
|
450
|
-
const dir = sessionStoreDir(rec.session)
|
|
451
|
-
mkdirSync(dir, { recursive: true })
|
|
452
|
-
const path = sessionRecordPath(rec.session)
|
|
453
|
-
const tmp = join(dir, `.session.json.${process.pid}.tmp`)
|
|
454
|
-
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n')
|
|
455
|
-
renameSync(tmp, path) // atomic within the dir: a concurrent reader sees the old record or the new one
|
|
456
|
-
const previousPublic = previous ? publicRecord(previous) : null
|
|
457
|
-
const nextPublic = publicRecord(rec)
|
|
458
|
-
if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
459
|
-
|| previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
|
|
460
|
-
recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note)
|
|
461
|
-
scheduleWatchNotifications(rec)
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
type WatchEntry = { watcher: string; createdAt: string }
|
|
466
|
-
export type SessionWatch = { target: string; createdAt: string }
|
|
467
|
-
const watchPath = (target: string) => sessionArtifactPath(target, 'watchers.json')
|
|
468
|
-
|
|
469
|
-
function readWatchEntries(target: string): WatchEntry[] {
|
|
470
|
-
try {
|
|
471
|
-
const raw = JSON.parse(readFileSync(watchPath(target), 'utf8')) as unknown
|
|
472
|
-
if (!Array.isArray(raw)) return []
|
|
473
|
-
const seen = new Set<string>()
|
|
474
|
-
return raw.flatMap((entry): WatchEntry[] => {
|
|
475
|
-
if (!entry || typeof entry !== 'object') return []
|
|
476
|
-
const watcher = (entry as WatchEntry).watcher
|
|
477
|
-
const createdAt = (entry as WatchEntry).createdAt
|
|
478
|
-
if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher)) return []
|
|
479
|
-
seen.add(watcher)
|
|
480
|
-
return [{ watcher, createdAt }]
|
|
481
|
-
})
|
|
482
|
-
} catch { return [] }
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function writeWatchEntries(target: string, entries: WatchEntry[]): void {
|
|
486
|
-
const path = watchPath(target)
|
|
487
|
-
if (!entries.length) { try { unlinkSync(path) } catch { /* already absent */ }; return }
|
|
488
|
-
const dir = sessionStoreDir(target)
|
|
489
|
-
mkdirSync(dir, { recursive: true })
|
|
490
|
-
const tmp = join(dir, `.watchers.json.${process.pid}.tmp`)
|
|
491
|
-
writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n')
|
|
492
|
-
renameSync(tmp, path)
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function managedWatchRecord(id: string): SessRec {
|
|
496
|
-
const rec = readRecord(id)
|
|
497
|
-
if (!rec?.governed) throw new ResourceConflict(`session ${id} is not a governed session and cannot participate in a durable watch`)
|
|
498
|
-
return rec
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function watchMessage(target: SessRec): string {
|
|
502
|
-
const status = target.status === 'awaiting'
|
|
503
|
-
? PROPOSAL_STATUS[target.proposal ?? 'nothing']
|
|
504
|
-
: target.status === 'active' ? 'working' : target.status
|
|
505
|
-
const note = target.note ? ` — ${target.note}` : ''
|
|
506
|
-
return `[spex watch] ${target.session} is ${status}${note}`
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function scheduleWatchNotifications(target: SessRec): void {
|
|
510
|
-
const watchers = readWatchEntries(target.session).map((entry) => entry.watcher)
|
|
511
|
-
if (!watchers.length) return
|
|
512
|
-
queueMicrotask(() => {
|
|
513
|
-
for (const watcher of watchers) {
|
|
514
|
-
void sendText(watcher, watchMessage(target), target.session).then((result) => {
|
|
515
|
-
if (!result.ok) console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`)
|
|
516
|
-
})
|
|
517
|
-
}
|
|
518
|
-
})
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
export async function subscribeSessionWatch(watcher: string, targets: string[]): Promise<{ watched: string[] }> {
|
|
522
|
-
managedWatchRecord(watcher)
|
|
523
|
-
const watched: string[] = []
|
|
524
|
-
for (const target of [...new Set(targets)]) {
|
|
525
|
-
if (target === watcher) throw new ResourceConflict('a session cannot watch itself')
|
|
526
|
-
const targetRecord = managedWatchRecord(target)
|
|
527
|
-
withRecordLockSync(target, () => {
|
|
528
|
-
const entries = readWatchEntries(target)
|
|
529
|
-
if (!entries.some((entry) => entry.watcher === watcher)) {
|
|
530
|
-
writeWatchEntries(target, [...entries, { watcher, createdAt: new Date().toISOString() }])
|
|
531
|
-
}
|
|
532
|
-
})
|
|
533
|
-
const delivered = await sendText(watcher, watchMessage(targetRecord), target)
|
|
534
|
-
if (!delivered.ok) throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`)
|
|
535
|
-
watched.push(target)
|
|
536
|
-
}
|
|
537
|
-
return { watched }
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
export function listSessionWatches(watcher: string): SessionWatch[] {
|
|
541
|
-
managedWatchRecord(watcher)
|
|
542
|
-
const watches: SessionWatch[] = []
|
|
543
|
-
for (const target of listSessionIds()) {
|
|
544
|
-
const entries = readWatchEntries(target)
|
|
545
|
-
const active = entries.filter((entry) => {
|
|
546
|
-
try { return !!readRecord(entry.watcher)?.governed } catch { return false }
|
|
547
|
-
})
|
|
548
|
-
if (active.length !== entries.length) writeWatchEntries(target, active)
|
|
549
|
-
for (const entry of active) if (entry.watcher === watcher) watches.push({ target, createdAt: entry.createdAt })
|
|
550
|
-
}
|
|
551
|
-
return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target))
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
export function cancelSessionWatch(watcher: string, targets: string[]): number {
|
|
555
|
-
managedWatchRecord(watcher)
|
|
556
|
-
let cancelled = 0
|
|
557
|
-
for (const target of [...new Set(targets)]) {
|
|
558
|
-
withRecordLockSync(target, () => {
|
|
559
|
-
const entries = readWatchEntries(target)
|
|
560
|
-
const kept = entries.filter((entry) => entry.watcher !== watcher)
|
|
561
|
-
if (kept.length !== entries.length) {
|
|
562
|
-
writeWatchEntries(target, kept)
|
|
563
|
-
cancelled++
|
|
564
|
-
}
|
|
565
|
-
})
|
|
566
|
-
}
|
|
567
|
-
return cancelled
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Share one liveness snapshot rather than spawning tmux for every displayed session.
|
|
571
|
-
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
|
|
572
|
-
|
|
573
|
-
// First pane per session wins; split only twice so titles may contain tabs.
|
|
574
|
-
export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
|
|
575
|
-
const m = new Map<string, { panePid?: number; title?: string }>()
|
|
576
|
-
for (const line of out.split('\n')) {
|
|
577
|
-
if (!line) continue
|
|
578
|
-
const t1 = line.indexOf('\t')
|
|
579
|
-
const name = (t1 < 0 ? line : line.slice(0, t1)).trim()
|
|
580
|
-
if (!name || m.has(name)) continue // first pane per session wins
|
|
581
|
-
if (t1 < 0) { m.set(name, {}); continue }
|
|
582
|
-
const rest = line.slice(t1 + 1)
|
|
583
|
-
const t2 = rest.indexOf('\t')
|
|
584
|
-
const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim())
|
|
585
|
-
const title = t2 < 0 ? '' : rest.slice(t2 + 1)
|
|
586
|
-
m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined })
|
|
587
|
-
}
|
|
588
|
-
return m
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Latch ESRCH per pid-file mtime so a recycled OS PID cannot revive an old session.
|
|
592
|
-
type PidEntry = { mtimeMs: number; pid: number; deadLatched: boolean }
|
|
593
|
-
const pidRegistry = new Map<string, PidEntry>()
|
|
594
|
-
function readAgentPid(p: string): number { try { return Number(readFileSync(p, 'utf8').trim()) } catch { return NaN } }
|
|
595
|
-
function agentAlive(id: string): boolean | undefined {
|
|
596
|
-
const pidPath = sessionArtifactPath(id, 'agent.pid')
|
|
597
|
-
let mtimeMs: number
|
|
598
|
-
try { mtimeMs = statSync(pidPath).mtimeMs } catch { pidRegistry.delete(id); return undefined } // no pid file → pre-registration
|
|
599
|
-
let e = pidRegistry.get(id)
|
|
600
|
-
if (!e || e.mtimeMs !== mtimeMs) { e = { mtimeMs, pid: readAgentPid(pidPath), deadLatched: false }; pidRegistry.set(id, e) }
|
|
601
|
-
if (e.deadLatched) return false // latched dead stays dead until a new write (fresh mtime)
|
|
602
|
-
if (!Number.isFinite(e.pid) || e.pid <= 0) return false
|
|
603
|
-
try { process.kill(e.pid, 0); return true }
|
|
604
|
-
catch (err) {
|
|
605
|
-
if ((err as NodeJS.ErrnoException).code === 'EPERM') return true // alive but not ours to signal
|
|
606
|
-
e.deadLatched = true // ESRCH → proven dead, latch it permanently
|
|
607
|
-
return false
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Only pre-agent.pid Codex sessions need the legacy whole-process scan.
|
|
612
|
-
export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean }[]): boolean {
|
|
613
|
-
return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
async function liveSnapshot(): Promise<LiveSnap> {
|
|
617
|
-
const windows = new Map<string, PaneProbe>()
|
|
618
|
-
const titles = new Map<string, string>()
|
|
619
|
-
let out: string
|
|
620
|
-
try {
|
|
621
|
-
// ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
|
|
622
|
-
out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_pid}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS)
|
|
623
|
-
} catch (e) {
|
|
624
|
-
// a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
|
|
625
|
-
// non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
|
|
626
|
-
return { probeFailed: probeTimedOut(e), windows, titles, sockets: new Set(), unproven: new Set() }
|
|
627
|
-
}
|
|
628
|
-
// the hot-tier pid verdict per windowed session (latch-consistent with hotSignature) + the legacy-scan gate.
|
|
629
|
-
const legacy: { harness: string; hasPid: boolean }[] = []
|
|
630
|
-
for (const [id, p] of parseLivePanes(out)) {
|
|
631
|
-
windows.set(id, { panePid: p.panePid, pidAlive: agentAlive(id) })
|
|
632
|
-
if (p.title) titles.set(id, p.title)
|
|
633
|
-
if (windows.get(id)!.pidAlive === undefined) {
|
|
634
|
-
// A corrupt row has no trustworthy harness to scan and renders liveness=unknown on its own. Letting this
|
|
635
|
-
// optional legacy enrichment throw would turn one diagnosable row into a 409 for the entire board.
|
|
636
|
-
try { const rec = readRecord(id); if (rec) legacy.push({ harness: rec.harness, hasPid: false }) }
|
|
637
|
-
catch (e) { if (!(e instanceof SessionRecordUnusable)) throw e }
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
// the whole-box ps table is gathered ONCE, and ONLY for the legacy pid-less-codex fallback (paneTreeRunsCodex).
|
|
641
|
-
if (needsCodexProcScan(legacy)) {
|
|
642
|
-
const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
|
|
643
|
-
if (procs) for (const probe of windows.values()) probe.procs = procs
|
|
644
|
-
}
|
|
645
|
-
// LISTENER probe for every windowed session, once, in parallel (a live listener, not a lingering socket
|
|
646
|
-
// file). A codex session has no rvSock → instant ENOENT → proven dead for the socket axis (codex ignores it).
|
|
647
|
-
// The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
|
|
648
|
-
// in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
|
|
649
|
-
const ids = [...windows.keys()]
|
|
650
|
-
const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
|
|
651
|
-
const sockets = new Set<string>()
|
|
652
|
-
const unproven = new Set<string>()
|
|
653
|
-
ids.forEach((id, i) => {
|
|
654
|
-
if (listening[i] === 'live') sockets.add(id)
|
|
655
|
-
else if (listening[i] === 'unproven') unproven.add(id)
|
|
656
|
-
})
|
|
657
|
-
return { probeFailed: false, windows, titles, sockets, unproven }
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Avoid process spawns on the hot path; old sessions without agent.pid remain warm-tier only.
|
|
661
|
-
let hotIds: string[] = []
|
|
662
|
-
let hotIdsAt = 0
|
|
663
|
-
export async function hotSignature(): Promise<string> {
|
|
664
|
-
const now = Date.now()
|
|
665
|
-
if (now - hotIdsAt >= 1000) { hotIds = listSessionIds(); hotIdsAt = now }
|
|
666
|
-
const pairs: string[] = []
|
|
667
|
-
const present: string[] = []
|
|
668
|
-
for (const id of hotIds) {
|
|
669
|
-
const alive = agentAlive(id)
|
|
670
|
-
if (alive === undefined) continue // no agent.pid → the warm tier's concern, not the hot death detector
|
|
671
|
-
present.push(id)
|
|
672
|
-
pairs.push(`${id}:${alive ? 1 : 0}`)
|
|
673
|
-
}
|
|
674
|
-
// prune latch entries for ids no longer registered (closed sessions), keeping the registry bounded.
|
|
675
|
-
const live = new Set(hotIds)
|
|
676
|
-
for (const k of [...pidRegistry.keys()]) if (!live.has(k)) pidRegistry.delete(k)
|
|
677
|
-
return pairs.sort().join(',') + '|' + present.sort().join(',')
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Include listener and title changes so watchers refresh without another store read.
|
|
681
|
-
export async function warmSignature(): Promise<string> {
|
|
682
|
-
const snap = await liveSnapshot()
|
|
683
|
-
return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
|
|
684
|
-
[...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' +
|
|
685
|
-
[...snap.titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
|
|
689
|
-
// NOT become) a session's headline activity. The board headline derives from the pane title ONLY for a
|
|
690
|
-
// harness whose pane title is its own task self-summary (`paneTitleIsSelfSummary`, an adapter capability —
|
|
691
|
-
// [[harness-adapter]]). claude qualifies (it writes its task summary into the OSC title), so we parse it with
|
|
692
|
-
// selfSummary (glyph-gated). codex does NOT — its pane title is a spinner glyph + the cwd FOLDER name, so
|
|
693
|
-
// returning it would headline the worktree folder, not the task; we refuse it (→ null) and sessionHeadline
|
|
694
|
-
// falls through to promptPreview (the launch prompt). The ONLY harness branch is the capability read here —
|
|
695
|
-
// no `if (codex)`, no glyph special-case; selfSummary stays the pure claude-title parser.
|
|
696
|
-
export function paneActivity(harness: Harness, paneTitle: string | null | undefined): string | null {
|
|
697
|
-
if (paneTitle == null || !harness.paneTitleIsSelfSummary) return null
|
|
698
|
-
return selfSummary(paneTitle)
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// @@@ selfSummary - the agent's OWN live one-line description, parsed from its tmux pane title — the SINGLE
|
|
702
|
-
// place the "is this the agent speaking?" rule lives, exported so it is unit-auditable. Claude Code sets that
|
|
703
|
-
// title via an OSC escape and ALWAYS leads it with a status glyph: ✳ (and its ✶✻✽✢ blink frames) when idle, a
|
|
704
|
-
// braille spinner frame (U+2800–U+28FF) while working. That leading glyph is the only reliable proof the
|
|
705
|
-
// title is the agent and not tmux's default — which, from pane birth until the first turn, is the HOST NAME
|
|
706
|
-
// (e.g. `ser581555022561`) or a bare `Claude Code` splash. So the glyph is REQUIRED: no leading glyph → null,
|
|
707
|
-
// and the caller keeps showing the launch-prompt placeholder instead of flickering through the host name and
|
|
708
|
-
// splash. The leading glyph run (with the spaces/`·` between and after) is stripped — the dashboard draws its
|
|
709
|
-
// own status dot, a frozen spinner frame is just noise — leaving only the summary text (null if it is empty).
|
|
710
|
-
// ONE regex is the single source of the glyph rule: it gates (requires ≥1 glyph) and strips in one match.
|
|
711
|
-
// The glyph gate alone is not enough: Claude Code emits a glyph-led SPLASH of its own app name (`✳ Claude
|
|
712
|
-
// Code`) between pane birth and its first real task summary — it CLEARS the glyph gate yet is the app naming
|
|
713
|
-
// itself, not the task. GENERIC_SUMMARY rejects that stripped splash too, so the row keeps its launch-prompt
|
|
714
|
-
// placeholder instead of flashing "Claude Code" for a tick (the glyph-LESS `Claude Code` splash was already
|
|
715
|
-
// rejected by the gate; this catches its glyph-led twin).
|
|
716
|
-
const GENERIC_SUMMARY = /^claude code$/i
|
|
717
|
-
export function selfSummary(paneTitle: string): string | null {
|
|
718
|
-
const m = /^[\s·]*(?:[✳✶✻✽✢⠀-⣿][\s·]*)+(.*)$/u.exec(paneTitle)
|
|
719
|
-
if (!m) return null
|
|
720
|
-
const text = m[1].trim()
|
|
721
|
-
return text && !GENERIC_SUMMARY.test(text) ? text : null
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// @@@ launchedAt - when we last started a tmux window for an id (set in launch()). claude needs ~15-20s
|
|
725
|
-
// after the window appears to recreate its rendezvous socket; in that window the socket is absent but the
|
|
726
|
-
// session is booting, NOT dead. reconcile consults this to report 'starting' (a distinct transient state)
|
|
727
|
-
// instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
|
|
728
|
-
// memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
|
|
729
|
-
const launchedAt = new Map<string, number>()
|
|
730
|
-
export const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
|
|
731
|
-
// window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
|
|
732
|
-
const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
|
|
733
|
-
// exit before readiness is retryable, but it is not proof of one specific cause
|
|
734
|
-
|
|
735
|
-
export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
736
|
-
if (!rec.session || rec.stopped || rec.archived) return 'offline'
|
|
737
|
-
// Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
|
|
738
|
-
// codex proves its launch-registered pid (with the legacy descendant-tree fallback). The 'starting' grace
|
|
739
|
-
// stays here: a just-launched agent whose online signal has not appeared yet reads 'starting', only past it
|
|
740
|
-
// 'offline'.
|
|
741
|
-
const h = harnessById(rec.harness || defaultHarness.id)
|
|
742
|
-
if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
|
|
743
|
-
if (snap.probeFailed) return 'unknown' // the probe failed — we can't tell, and MUST NOT guess offline
|
|
744
|
-
// not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
|
|
745
|
-
// off a full-but-alive backlog), death is UNPROVEN: `unknown`, never a false `offline` a supervisor would
|
|
746
|
-
// act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
|
|
747
|
-
if (snap.unproven.has(rec.session)) return 'unknown'
|
|
748
|
-
const at = launchedAt.get(rec.session)
|
|
749
|
-
if (at && Date.now() - at < BOOT_GRACE_MS) return 'starting'
|
|
750
|
-
// A dead TRANSPORT is not a dead AGENT. The socket path is keyed by session id alone, so a foreign teardown
|
|
751
|
-
// (or a stray rm) can unlink it out from under its own live listener: the agent keeps working, unreachable,
|
|
752
|
-
// and every path-connect ENOENTs — which the adapter axis above reports as proven death. The registered
|
|
753
|
-
// agent.pid is a SECOND, independent witness, and while it still answers, death is UNPROVEN: `unknown`, not
|
|
754
|
-
// the `offline` that disarms the relaunch guard and invites a human to kill a working agent. Same rule as
|
|
755
|
-
// the probe-failure branch (issue #40), one layer down: only a corpse both witnesses agree on is actionable.
|
|
756
|
-
if (agentAlive(rec.session) === true) return 'unknown'
|
|
757
|
-
return 'offline'
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
|
|
761
|
-
// record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
|
|
762
|
-
// about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
|
|
763
|
-
if (retirementReason(rec)) return 'retired'
|
|
764
|
-
if (rec.archived) return 'offline'
|
|
765
|
-
if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
|
|
766
|
-
if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
|
|
767
|
-
const lv = liveness(rec, snap)
|
|
768
|
-
if (lv !== 'online') return lv // 'offline' | 'starting' | 'unknown'
|
|
769
|
-
return rec.status === 'idle' ? 'idle' : 'working'
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// resolve a session id to its record + worktree. Now a DIRECT store read (the record carries worktree_path),
|
|
773
|
-
// not a scan of every worktree reading its `.session` — O(1) and exact. null when the id has no governed-or-not
|
|
774
|
-
// record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/resume/merge/close/…) are unchanged.
|
|
775
|
-
async function findWorktree(id: string): Promise<{ path: string; branch: string | null; rec: SessRec } | null> {
|
|
776
|
-
const rec = readRecord(id)
|
|
777
|
-
if (!rec) return null
|
|
778
|
-
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// @@@ identity WITHOUT the gates - reviewPayload answers two different questions at once: who is this
|
|
782
|
-
// session (a store read, free) and how does its branch stand against main (ahead count, dirty scan, a
|
|
783
|
-
// merge-tree conflict probe — 646 ms and 8 git children on a far-diverged branch). A consumer that renders
|
|
784
|
-
// no gates strip should not buy the second one. The record already holds the identity half.
|
|
785
|
-
export type ReviewIdentity = { id: string; node: string | null; branch: string | null; label: string }
|
|
786
|
-
export function reviewIdentity(id: string): ReviewIdentity | null {
|
|
787
|
-
const rec = readRecord(id)
|
|
788
|
-
if (!rec) return null
|
|
789
|
-
return {
|
|
790
|
-
id,
|
|
791
|
-
node: rec.node,
|
|
792
|
-
branch: rec.branch,
|
|
793
|
-
label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
function corruptSession(id: string, entry: { path: string; error: string }): Session {
|
|
798
|
-
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
799
|
-
return {
|
|
800
|
-
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
801
|
-
parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
|
|
802
|
-
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
803
|
-
note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
|
|
804
|
-
activity: null, sortKey: null, archiveHazard: null,
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
|
|
809
|
-
const prompt = readPromptFile(rec.session) // the originating ask, captured at launch (store artifact; null for old sessions)
|
|
810
|
-
// activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
|
|
811
|
-
// dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
|
|
812
|
-
const showActivity = lv === 'online'
|
|
813
|
-
const act = showActivity ? activity : null
|
|
814
|
-
const pp = prompt ? oneLinePreview(prompt) : null
|
|
815
|
-
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp }
|
|
816
|
-
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
817
|
-
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
821
|
-
return withRecordLock(id, async () => {
|
|
822
|
-
const wt = await findWorktree(id)
|
|
823
|
-
if (!wt) return false
|
|
824
|
-
writeRecord({ ...wt.rec, name: name.trim() || null })
|
|
825
|
-
return true
|
|
826
|
-
})
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
|
|
830
|
-
return withRecordLock(id, async () => {
|
|
831
|
-
const wt = await findWorktree(id)
|
|
832
|
-
if (!wt) return false
|
|
833
|
-
writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
|
|
834
|
-
return true
|
|
835
|
-
})
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded. A record we
|
|
839
|
-
// cannot read simply has no prompt to report — a READ accessor must not turn an unreadable record into an
|
|
840
|
-
// error for the surface asking about it; the row itself already carries the diagnosis.
|
|
841
|
-
export async function sessionPrompt(id: string): Promise<string | null> {
|
|
842
|
-
try { return readRecord(id) ? readPromptFile(id) : null }
|
|
843
|
-
catch (e) { if (e instanceof SessionRecordUnusable) return null; throw e }
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// Preserve rows through a transient record-read failure; prune after the store entry disappears.
|
|
847
|
-
const lastKnownSession = new Map<string, Session>()
|
|
848
|
-
|
|
849
|
-
export async function listSessions(includeArchived = false): Promise<Session[]> {
|
|
850
|
-
// ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
|
|
851
|
-
// every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
852
|
-
const [ids, snap] = await Promise.all([
|
|
853
|
-
Promise.resolve(listSessionIds()), liveSnapshot(),
|
|
854
|
-
])
|
|
855
|
-
// Freeze one record snapshot for both the census join and row projection. A second full read after an awaited
|
|
856
|
-
// probe could pair record A with thread identity B and accidentally treat a missing census entry as clean.
|
|
857
|
-
const snapshots = new Map<string, { entry: PublicRecordEntry; rec: SessRec | null }>()
|
|
858
|
-
for (const id of ids) {
|
|
859
|
-
try {
|
|
860
|
-
const entry = readPublicRecordEntry(id)
|
|
861
|
-
snapshots.set(id, { entry, rec: entry.kind === 'ok' ? fromRaw(entry.raw) : null })
|
|
862
|
-
} catch { /* guardSession below preserves the last-known row for a transient read failure */ }
|
|
863
|
-
}
|
|
864
|
-
// Only archived adapter records need the resident-ID join. If there are none, this read path performs zero
|
|
865
|
-
// control-plane probes; resources still owns the full turn/read probe for its detailed report.
|
|
866
|
-
const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && entry.liveness === null && rec && rec.governed && rec.archived && rec.harnessSessionId
|
|
867
|
-
? [{ ...rec, harness: rec.harness || defaultHarness.id }]
|
|
868
|
-
: [])
|
|
869
|
-
const residentCensus = censusRecords.length ? await adapterLoadedReferenceState(censusRecords) : new Map()
|
|
870
|
-
// A record can change while the one census is in flight. Mark such rows conservatively; never let the stale
|
|
871
|
-
// proof hide them. This is a bounded storage read, not another adapter RPC.
|
|
872
|
-
const changedDuringCensus = new Set<string>()
|
|
873
|
-
for (const rec of censusRecords) {
|
|
874
|
-
try {
|
|
875
|
-
const current = readPublicRecordEntry(rec.session)
|
|
876
|
-
const before = snapshots.get(rec.session)?.entry
|
|
877
|
-
if (current.kind !== 'ok' || before?.kind !== 'ok' || JSON.stringify(current.raw) !== JSON.stringify(before.raw)) changedDuringCensus.add(rec.session)
|
|
878
|
-
} catch { changedDuringCensus.add(rec.session) }
|
|
879
|
-
}
|
|
880
|
-
const rows = ids.map((id) => guardSession(id, () => {
|
|
881
|
-
// a record we cannot READ still has a row: it is a session that exists and whose state is unknowable, which
|
|
882
|
-
// is a thing to act on, not a thing to hide. It carries its own status and names the file, so the human can
|
|
883
|
-
// see the file and close it — the alternative (dropping it) is what made a live session read as gone.
|
|
884
|
-
const snapshot = snapshots.get(id)
|
|
885
|
-
if (!snapshot) throw new Error(`session ${id} record snapshot unavailable`)
|
|
886
|
-
const { entry } = snapshot
|
|
887
|
-
// The corrupt row becomes the LAST-KNOWN row, never a deletion. Dropping it would mean the next poll that
|
|
888
|
-
// hits a transient read failure has nothing to fall back on and the row vanishes — re-opening the exact
|
|
889
|
-
// hole this branch closes, one poll later. `corrupt` is a true reading, so it is worth remembering.
|
|
890
|
-
if (entry.kind === 'corrupt') { const c = corruptSession(id, entry); lastKnownSession.set(id, c); return c }
|
|
891
|
-
const rec = snapshot.rec
|
|
892
|
-
if (!rec || !rec.governed) { lastKnownSession.delete(id); return null } // no record, or a self-launched (non-board) one
|
|
893
|
-
// A forced public liveness comes only from the shared record projection. Do not let live process/thread
|
|
894
|
-
// evidence punch through it (including archive hazard repair).
|
|
895
|
-
if (entry.kind === 'ok' && entry.liveness === 'offline') {
|
|
896
|
-
const pending = toSession(rec, 'offline', 'offline')
|
|
897
|
-
lastKnownSession.set(id, pending)
|
|
898
|
-
return pending
|
|
899
|
-
}
|
|
900
|
-
// the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
|
|
901
|
-
// is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
|
|
902
|
-
const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id))
|
|
903
|
-
const sessionHarness = harnessById(rec.harness || defaultHarness.id)
|
|
904
|
-
const resident = rec.harnessSessionId
|
|
905
|
-
? residentCensus.get(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`)
|
|
906
|
-
: undefined
|
|
907
|
-
const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!rec.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length
|
|
908
|
-
const physical = rec.archived
|
|
909
|
-
? (sessionHarness.runtimeOwnership === 'adapter'
|
|
910
|
-
? (resident && !resident.healthy ? 'unknown' : resident?.loaded ? 'online' : snap.windows.has(id) ? 'online' : 'offline')
|
|
911
|
-
: liveness({ ...rec, archived: false, stopped: false }, snap))
|
|
912
|
-
: null
|
|
913
|
-
// Only a physically-offline record projects as archived. A legacy archived+live/unknown record is exposed
|
|
914
|
-
// as ordinary working-set state with its real liveness/status and one backend-owned hazard marker. A
|
|
915
|
-
// missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
|
|
916
|
-
// unloaded, so it remains visible until an explicit archive repair.
|
|
917
|
-
const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true)
|
|
918
|
-
const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec
|
|
919
|
-
const projectedLv = projected === rec ? liveness(rec, snap) : physical!
|
|
920
|
-
const s = toSession(projected, reconcile(projected, snap), projectedLv, activity)
|
|
921
|
-
if (projected !== rec) s.archiveHazard = changedDuringCensus.has(id)
|
|
922
|
-
? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
|
|
923
|
-
: hasValidColdProof(rec)
|
|
924
|
-
? residentRequired && !resident
|
|
925
|
-
? 'archived runtime hazard: adapter resident-reference census missing; exact unload is unproven'
|
|
926
|
-
: resident && !resident.healthy
|
|
927
|
-
? `archived runtime hazard: adapter resident-reference census is unknown (${resident.error || 'probe failed'})`
|
|
928
|
-
: resident?.loaded
|
|
929
|
-
? 'archived runtime hazard: target adapter thread is still loaded'
|
|
930
|
-
: `archived runtime hazard: record says archived but physical liveness is ${physical}`
|
|
931
|
-
: 'archived runtime hazard: record has no durable cold witness; exact adapter unload is unproven'
|
|
932
|
-
if (rec.adapterRecovery) s.archiveHazard = `archive adapter recovery required: ${rec.adapterRecovery}`
|
|
933
|
-
lastKnownSession.set(id, s)
|
|
934
|
-
return s
|
|
935
|
-
}, () => {
|
|
936
|
-
// DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
|
|
937
|
-
// session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
|
|
938
|
-
// show yet, and it reappears on the next build.)
|
|
939
|
-
return lastKnownSession.get(id) ?? null
|
|
940
|
-
}))
|
|
941
|
-
// prune last-known entries for ids that no longer appear at all (genuinely removed), keeping it bounded.
|
|
942
|
-
const liveIds = new Set(ids)
|
|
943
|
-
for (const k of [...lastKnownSession.keys()]) if (!liveIds.has(k)) lastKnownSession.delete(k)
|
|
944
|
-
return rows.filter((s): s is Session => s != null && (includeArchived || !s.archived))
|
|
945
|
-
.sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// a per-session read guard mirroring resilience.guardWorktree but keyed on the store record (not a worktree
|
|
949
|
-
// path): run `primary`; if it throws AND the record dir still exists, the failure is transient → serve the
|
|
950
|
-
// `degraded` fallback; if the dir is gone (a genuine close), return null (omit). No async git, so it's sync.
|
|
951
|
-
function guardSession(id: string, primary: () => Session | null, degraded: () => Session | null): Session | null {
|
|
952
|
-
try { return primary() }
|
|
953
|
-
catch { return existsSync(sessionStoreDir(id)) ? degraded() : null }
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
|
|
957
|
-
export type ApiBaseInfo = { url: string; source: ApiBaseSource }
|
|
958
|
-
const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
|
|
959
|
-
// the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
|
|
960
|
-
// `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
|
|
961
|
-
function explicitApiFlag(): string | null {
|
|
962
|
-
const argv = process.argv
|
|
963
|
-
const ai = argv.indexOf('--api')
|
|
964
|
-
if (ai >= 0) {
|
|
965
|
-
const v = argv[ai + 1]
|
|
966
|
-
if (!v || v.startsWith('--')) throw usageError('--api expects a URL (e.g. --api http://127.0.0.1:8901)')
|
|
967
|
-
const withScheme = v.includes('://') ? v : `http://${v}`
|
|
968
|
-
try { new URL(withScheme) } catch { throw usageError(`--api: not a URL: ${v}`) }
|
|
969
|
-
return withScheme.replace(/\/+$/, '')
|
|
970
|
-
}
|
|
971
|
-
if (argv[2] === 'serve' || argv[2] === 'dashboard') return null // their --port is a bind port, not routing
|
|
972
|
-
const pi = argv.indexOf('--port')
|
|
973
|
-
if (pi >= 0) {
|
|
974
|
-
const v = argv[pi + 1]
|
|
975
|
-
if (!v || !Number.isInteger(Number(v))) throw usageError('--port expects an integer (localhost sugar for --api http://127.0.0.1:<n>)')
|
|
976
|
-
return `http://127.0.0.1:${v}`
|
|
977
|
-
}
|
|
978
|
-
return null
|
|
979
|
-
}
|
|
980
|
-
// the cwd project's recorded backend ({url,pid}, written by `spex serve` at bind time into the per-project
|
|
981
|
-
// runtime tier), trusted only after a live /health probe — a stale record must never swallow a command.
|
|
982
|
-
async function liveRecordUrl(): Promise<string | null> {
|
|
983
|
-
let file: string
|
|
984
|
-
try { file = join(runtimeRoot(), 'backend.json') } catch { return null } // cwd not in a git repo → nothing to discover
|
|
985
|
-
let url = ''
|
|
986
|
-
try { const rec = JSON.parse(readFileSync(file, 'utf8')); if (typeof rec?.url === 'string') url = rec.url.trim() } catch { return null }
|
|
987
|
-
if (!url) return null
|
|
988
|
-
const ctrl = new AbortController()
|
|
989
|
-
const t = setTimeout(() => ctrl.abort(), 600)
|
|
990
|
-
try { return (await fetch(`${url}/health`, { signal: ctrl.signal })).ok ? url : null }
|
|
991
|
-
catch { return null }
|
|
992
|
-
finally { clearTimeout(t) }
|
|
993
|
-
}
|
|
994
|
-
async function resolveApiBase(): Promise<ApiBaseInfo> {
|
|
995
|
-
const flag = explicitApiFlag()
|
|
996
|
-
if (flag) return { url: flag, source: 'flag' }
|
|
997
|
-
const env = process.env.SPEXCODE_API_URL?.trim() || null
|
|
998
|
-
if (process.env.SPEXCODE_SESSION_ID?.trim()) {
|
|
999
|
-
if (env) return { url: env, source: 'worker-env' }
|
|
1000
|
-
const rec = await liveRecordUrl()
|
|
1001
|
-
if (rec) return { url: rec, source: 'record' }
|
|
1002
|
-
} else {
|
|
1003
|
-
const rec = await liveRecordUrl()
|
|
1004
|
-
if (rec) return { url: rec, source: 'record' }
|
|
1005
|
-
if (env) return { url: env, source: 'env-fallback' }
|
|
1006
|
-
}
|
|
1007
|
-
return { url: `http://127.0.0.1:${process.env.PORT || 8787}`, source: 'default' }
|
|
1008
|
-
}
|
|
1009
|
-
let apiBaseMemo: Promise<ApiBaseInfo> | null = null
|
|
1010
|
-
export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
|
|
1011
|
-
export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
|
|
1012
|
-
|
|
1013
|
-
export const ownSessionId = envSessionId
|
|
1014
|
-
|
|
1015
|
-
export type MsgSender = { id: string; label: string | null }
|
|
1016
|
-
export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
1017
|
-
if (!sender) return text
|
|
1018
|
-
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
|
|
1019
|
-
return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
|
|
1020
|
-
}
|
|
1021
|
-
export const withNoteReplyHint = (text: string): string =>
|
|
1022
|
-
`${text}\n\n— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For a simple answer awaiting the next message, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`
|
|
1023
|
-
export const withTerminalReplyHint = (text: string): string =>
|
|
1024
|
-
`${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`
|
|
1025
|
-
export const slugify = (s: string | null) =>
|
|
1026
|
-
(s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
|
|
1027
|
-
|
|
1028
|
-
const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
|
|
1029
|
-
export const nodeFromPrompt = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
|
|
1030
|
-
|
|
1031
|
-
type CommandPreset = Pick<ConfigPreset, 'name' | 'body'>
|
|
1032
|
-
type CommandSpec = Pick<SpecLite, 'id' | 'path'>
|
|
1033
|
-
|
|
1034
|
-
export function composeCommandPrompt(raw: string, presets: CommandPreset[], specs: CommandSpec[]): string {
|
|
1035
|
-
const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/)
|
|
1036
|
-
if (!match) return raw
|
|
1037
|
-
const preset = presets.find((p) => p.name === match[1])
|
|
1038
|
-
if (!preset) return raw
|
|
1039
|
-
|
|
1040
|
-
const ids: string[] = []
|
|
1041
|
-
const allMentions = new RegExp(MENTION.source, 'gu')
|
|
1042
|
-
const free = match[2].replace(allMentions, (_, id: string) => { ids.push(id); return '' }).trim()
|
|
1043
|
-
const targets = ids.length
|
|
1044
|
-
? ids.map((id) => {
|
|
1045
|
-
const spec = specs.find((s) => s.id === id)
|
|
1046
|
-
const path = spec?.path.replace(/^\.spec\//, '').replace(/\/spec\.md$/, '')
|
|
1047
|
-
return path ? `- [[${id}]] — ${path}` : `- [[${id}]]`
|
|
1048
|
-
}).join('\n')
|
|
1049
|
-
: '(No target was mentioned. If the prompt names the scope, use it; otherwise ask the human to define the scope before proceeding — unless this task needs no scope, in which case proceed.)'
|
|
1050
|
-
const body = preset.body.includes('{{targets}}')
|
|
1051
|
-
? preset.body.replace('{{targets}}', targets)
|
|
1052
|
-
: ids.length ? `${preset.body}\n\n${targets}` : preset.body
|
|
1053
|
-
return free ? `${body}\n\n${free}` : body
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
// Load only the one live preset named by the raw invocation. Both session creation and sendText call this seam, so
|
|
1057
|
-
// launch and an existing session's inbox resolve identical plugin data with identical target semantics.
|
|
1058
|
-
export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpec[]): Promise<string> {
|
|
1059
|
-
const commandName = raw.match(/^\/(\S+)/)?.[1]
|
|
1060
|
-
const preset = commandName ? loadConfig().find((p) => p.name === commandName) : undefined
|
|
1061
|
-
if (!preset) return raw
|
|
1062
|
-
const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
|
|
1063
|
-
return composeCommandPrompt(raw, [preset], specs)
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
type SessionPromptTarget = Pick<SessRec, 'session' | 'harness'>
|
|
1067
|
-
type SessionPromptOptions = {
|
|
1068
|
-
from?: string
|
|
1069
|
-
replyVia?: 'note'
|
|
1070
|
-
loadedSpecs?: CommandSpec[]
|
|
1071
|
-
suffix?: string
|
|
1072
|
-
}
|
|
1073
|
-
export type ComposedSessionPrompt = { text: string; replyVia?: 'note' }
|
|
1074
|
-
|
|
1075
|
-
// @@@ composeSessionPrompt - the ONE prompt-delivery seam: raw caller text + target session become the
|
|
1076
|
-
// exact text handed to an adapter. Launch, ordinary input, CLI send, issue dispatch, watch greetings, and
|
|
1077
|
-
// merge all enter here (directly or through sendText). `replyVia` is target readability: an explicit note
|
|
1078
|
-
// request wins; otherwise a headless adapter defaults to note. This function alone decides and appends the
|
|
1079
|
-
// note/terminal inserts, so clients never own the policy or duplicate the phrase.
|
|
1080
|
-
export async function composeSessionPrompt(raw: string, target: SessionPromptTarget, opts: SessionPromptOptions = {}): Promise<ComposedSessionPrompt> {
|
|
1081
|
-
const resolved = await resolveCommandPrompt(raw, opts.loadedSpecs)
|
|
1082
|
-
const prompt = opts.suffix ? `${resolved}${opts.suffix}` : resolved
|
|
1083
|
-
const h = harnessById(target.harness || defaultHarness.id)
|
|
1084
|
-
const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
|
|
1085
|
-
const text = replyVia === 'note' ? withNoteReplyHint(prompt)
|
|
1086
|
-
: !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
|
|
1087
|
-
return { text: optionSafe(text), ...(replyVia ? { replyVia } : {}) }
|
|
1088
|
-
}
|
|
1089
|
-
const optionSafe = (text: string) => text.startsWith('-') ? ` ${text}` : text
|
|
1090
|
-
const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
|
|
1091
|
-
const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
|
|
1092
|
-
export function titleFromPrompt(prompt: string): string | null {
|
|
1093
|
-
const first = stripIdentityTokens(prompt || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
1094
|
-
const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
|
|
1095
|
-
if (!words) return null
|
|
1096
|
-
return words.length > 50 ? words.slice(0, 49).trimEnd() + '…' : words
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
// @@@ launchScript - the WHOLE launch invocation (rendezvous env prefix + harness command + the human prompt)
|
|
1100
|
-
// is written to an ephemeral `launch.sh` in the session's GLOBAL store and
|
|
1101
|
-
// run via `bash <file>`, NOT typed inline. Inline send-keys TRUNCATES past ~2KB (the launch-prompt-limit trap),
|
|
1102
|
-
// and a long human prompt + spec pointer can exceed it; a file has no length limit
|
|
1103
|
-
// and the only thing send-keys types is the short `bash <file>` line. It's the SAME command the inline path
|
|
1104
|
-
// ran (env prefix exports the rendezvous vars to the claude child), just relocated to a file. Liveness no
|
|
1105
|
-
// longer cares what the pane's foreground command is: claude runs as a child of bash (and, via the
|
|
1106
|
-
// `reclaude` wrapper, a grandchild), so the pane command is the wrapper/shell — reconcile reads claude's
|
|
1107
|
-
// rendezvous socket instead (present while claude is alive, gone once it exits). The file lives OUTSIDE the
|
|
1108
|
-
// worktree (in the store, keyed by session_id), so it never pollutes the spec/code work.
|
|
1109
|
-
// the launch command for THIS session ([[launcher-select]] resume-launcher-pin): the RESOLVED base command
|
|
1110
|
-
// PINNED on the record at creation wins — so a (re)launch replays the EXACT launcher that made the conversation
|
|
1111
|
-
// (and its config-dir env), never re-resolving against a since-changed default that would send `--resume` to the
|
|
1112
|
-
// wrong config dir and lose the transcript. Fall back to the named-launcher resolution (an old record with a
|
|
1113
|
-
// launcher name but no pinned cmd; fail-loud on a since-removed launcher), then undefined (truly old record →
|
|
1114
|
-
// the harness adapter's ambient resolution, best-effort).
|
|
1115
|
-
export function launcherCmd(rec: SessRec): string | undefined {
|
|
1116
|
-
if (rec.launchCmd) return rec.launchCmd
|
|
1117
|
-
return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
|
|
1118
|
-
}
|
|
1119
|
-
// @@@ launch preflight - the launch transport's OWN settled failures, checked before a tmux window is ever
|
|
1120
|
-
// opened. Each is a fact about this machine right now that no number of attempts can change: the worktree the
|
|
1121
|
-
// agent would run in, the branch it would commit to, the command that would start it. Answering them here is
|
|
1122
|
-
// what turns a certain failure into ONE loud, named refusal instead of a launch that fast-exits and is retried
|
|
1123
|
-
// on a wall clock. Everything the transport CANNOT settle (a launcher that races its own daemon) still reaches
|
|
1124
|
-
// the bounded retry, and the harness's own settled failures are the adapter's to name (fatalLaunchOutput).
|
|
1125
|
-
export type LaunchBlock = { code: 'no-worktree' | 'no-branch' | 'no-launcher'; message: string }
|
|
1126
|
-
// does this ref resolve to a commit? Through git.ts's git() so a hook's exported GIT_DIR can't misdirect
|
|
1127
|
-
// discovery. `--verify --quiet` answers a MISSING ref with a bare non-zero exit and no stderr, while a broken
|
|
1128
|
-
// repo/timeout writes stderr — so only the silent failure is read as absence. A probe that could not answer
|
|
1129
|
-
// reads as "exists": the preflight refuses on a PROVEN absence, never on a failed probe ([[state]]'s board
|
|
1130
|
-
// honesty rule applied to launch).
|
|
1131
|
-
function refExists(cwd: string, ref: string): boolean {
|
|
1132
|
-
try { return !!git(['-C', cwd, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim() }
|
|
1133
|
-
catch (e) { return String((e as { stderr?: string })?.stderr ?? '').trim() !== '' }
|
|
1134
|
-
}
|
|
1135
|
-
export function launchPreflight(rec: SessRec): LaunchBlock | null {
|
|
1136
|
-
if (!rec.worktreePath || !existsSync(rec.worktreePath))
|
|
1137
|
-
return { code: 'no-worktree', message: `session ${rec.session.slice(0, 8)}: its worktree ${rec.worktreePath || '(unrecorded)'} does not exist — there is nothing to launch in. If the work merged and the worktree was removed, close the session; otherwise restore the worktree first.` }
|
|
1138
|
-
if (rec.branch && !refExists(rec.worktreePath, rec.branch))
|
|
1139
|
-
return { code: 'no-branch', message: `session ${rec.session.slice(0, 8)}: its branch ${rec.branch} no longer exists — a relaunch would put the agent on a detached or wrong ref. Restore the branch, or close the session.` }
|
|
1140
|
-
let cmd: string | undefined
|
|
1141
|
-
try { cmd = launcherCmd(rec) }
|
|
1142
|
-
catch (e) { return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its launcher cannot be resolved — ${e instanceof Error ? e.message : e}` } }
|
|
1143
|
-
// only an ABSOLUTE command is checkable here; a bare name is the shell's PATH lookup at launch time, and
|
|
1144
|
-
// guessing at it would refuse launches that work. Certainty is the whole point of a preflight.
|
|
1145
|
-
const bin = (cmd ?? '').trim().split(/\s+/)[0]
|
|
1146
|
-
if (bin && isAbsolute(bin) && !existsSync(bin))
|
|
1147
|
-
return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its pinned launcher command ${bin} is not on this machine — every launch of it will fail until the path is restored or the session is re-dispatched under a launcher that exists.` }
|
|
1148
|
-
return null
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
// @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
|
|
1152
|
-
// invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
|
|
1153
|
-
export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
|
|
1154
|
-
const file = join(storeDir(id), 'launch.sh')
|
|
1155
|
-
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
1156
|
-
// createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
|
|
1157
|
-
// agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
|
|
1158
|
-
// `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
|
|
1159
|
-
// ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
|
|
1160
|
-
const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
|
|
1161
|
-
// @@@ birth registration - record the AGENT's real pid BEFORE exec, the anchor of the 100ms hot death tier
|
|
1162
|
-
// ([[state]]). Each attempt runs `sh -c '<pid-write>; exec env <invocation>'`: the sh writes its own `$$` to
|
|
1163
|
-
// agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
|
|
1164
|
-
// (claude: env→(reclaude→)claude; codex: env→bash -lc <script> whose last line is `exec codex … resume`), and
|
|
1165
|
-
// `$$` therefore IS the launched agent's pid. `env` carries the leading `VAR=val` assignments (an env prefix
|
|
1166
|
-
// can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shQuote) so the
|
|
1167
|
-
// invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
|
|
1168
|
-
// parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
|
|
1169
|
-
const pidPath = join(storeDir(id), 'agent.pid')
|
|
1170
|
-
const born = `sh -c ${shQuote(`printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`
|
|
1171
|
-
// Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
|
|
1172
|
-
// ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
|
|
1173
|
-
// has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
|
|
1174
|
-
// session end and is NEVER retried — the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this
|
|
1175
|
-
// retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
|
|
1176
|
-
// only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
|
|
1177
|
-
// attempts, then give up).
|
|
1178
|
-
// A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
|
|
1179
|
-
// app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
|
|
1180
|
-
// retry loop is a runtime capability rather than a harness-id branch.
|
|
1181
|
-
// @@@ retry only what retrying can fix - a fast exit says the launcher stopped before readiness, which is
|
|
1182
|
-
// reason enough to try again but never a diagnosis. So after a fast exit the script reads what the harness
|
|
1183
|
-
// actually SAID and matches it against the ADAPTER's own settled-failure patterns ([[harness-adapter]]
|
|
1184
|
-
// fatalLaunchOutput). A match means this command cannot succeed however many times we run it: stop at one
|
|
1185
|
-
// attempt and let the harness's own line be the last thing on the pane, instead of spending a certain failure
|
|
1186
|
-
// three times and burying the reason. No match keeps the plain bounded retry.
|
|
1187
|
-
//
|
|
1188
|
-
// It reads the PANE, not the agent's streams. Capturing stderr through a pipe missed the answer entirely —
|
|
1189
|
-
// measured against real reclaude, "No conversation found with session ID" arrives on STDOUT, so a
|
|
1190
|
-
// stderr-only capture classified nothing and retried a certain failure three times (the unit test passed
|
|
1191
|
-
// only because its stub printed to the stream the implementation happened to watch). Redirecting stdout too
|
|
1192
|
-
// would be worse: a TUI that finds stdout is not a terminal stops being a TUI. The pane already holds both
|
|
1193
|
-
// streams exactly as the human sees them, and the script runs inside that pane — so it just asks tmux.
|
|
1194
|
-
const fatal = (harness.fatalLaunchOutput ?? []).join('|')
|
|
1195
|
-
const launchBody = harness.launchOneShot ? [born, ''] : [
|
|
1196
|
-
`for __spex_try in 1 2 3; do`,
|
|
1197
|
-
` __spex_t0=$SECONDS`,
|
|
1198
|
-
// @@@ classify THIS attempt only - the pane is a scrollback, so it also holds every earlier attempt and
|
|
1199
|
-
// every earlier launch that ever ran in this window. Matching the whole capture would let a stale
|
|
1200
|
-
// settled-failure line from minutes ago condemn an unrelated fast exit and cut a launch that retrying
|
|
1201
|
-
// WOULD have recovered — the exact mirror of the miss this classifier exists to fix. So each attempt
|
|
1202
|
-
// stamps a line unique to (this run, this attempt) and the match starts after it. The run's pid is what
|
|
1203
|
-
// makes it unique across relaunches, which reuse the session id.
|
|
1204
|
-
` __spex_mark="attempt $__spex_try start $$"`,
|
|
1205
|
-
` printf '[spex launch] %s\\n' "$__spex_mark"`,
|
|
1206
|
-
` ${born}`,
|
|
1207
|
-
` __spex_rc=$?`,
|
|
1208
|
-
` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
|
|
1209
|
-
...(fatal ? [
|
|
1210
|
-
// -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
|
|
1211
|
-
// can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
|
|
1212
|
-
// bounded retry stands.
|
|
1213
|
-
` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
|
|
1214
|
-
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
1215
|
-
` exit $__spex_rc`,
|
|
1216
|
-
` fi`,
|
|
1217
|
-
] : []),
|
|
1218
|
-
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
1219
|
-
` sleep 2`,
|
|
1220
|
-
`done`,
|
|
1221
|
-
`exit $__spex_rc`,
|
|
1222
|
-
``,
|
|
1223
|
-
]
|
|
1224
|
-
writeFileSync(file, launchBody.join('\n'))
|
|
1225
|
-
return file
|
|
1226
|
-
}
|
|
1227
|
-
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
|
|
1228
|
-
// record the transport path THIS runtime hands the agent, before anything reads it (launchScript bakes it
|
|
1229
|
-
// into the launch env). Same kind of launch-time fact as agent.pid, and the reason a session's socket is
|
|
1230
|
-
// reachable only from the world it belongs to ([[harness-adapter]] rendezvous socket).
|
|
1231
|
-
if (harness.ownsRendezvous) stampRvSock(id)
|
|
1232
|
-
await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path])
|
|
1233
|
-
await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
|
|
1234
|
-
await tmux(['send-keys', '-t', id, 'Enter'])
|
|
1235
|
-
launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
const OCCUPIES_SLOT = new Set<DisplayStatus>(['working', 'parked', 'starting']) // starting's boot window is also held via `launching`
|
|
1240
|
-
function isOccupying(s: Session, snap: LiveSnap): boolean {
|
|
1241
|
-
if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
|
|
1242
|
-
const rec = readRecord(s.id)
|
|
1243
|
-
if (!rec) return false
|
|
1244
|
-
return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online'
|
|
1245
|
-
}
|
|
1246
|
-
// sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
|
|
1247
|
-
// `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
|
|
1248
|
-
// over-launch and blow past the cap. We hold the slot here from launch until the agent is online (waitForReady)
|
|
1249
|
-
// or it times out.
|
|
1250
|
-
// In-memory in the single server process (the only drainer) — lost on restart, which is fine: a restart drains
|
|
1251
|
-
// the durable `queued` worktrees fresh with nothing in flight.
|
|
1252
|
-
const launching = new Set<string>()
|
|
1253
|
-
// A queued launch and a cold archive must not cross between their final read and record write. This is a
|
|
1254
|
-
// narrow per-session intent latch, not a second cleanup primitive; the launch path simply leaves an archiving id
|
|
1255
|
-
// alone and the archive path re-probes before filing.
|
|
1256
|
-
const archiving = new Set<string>()
|
|
1257
|
-
const transitionTails = new Map<string, Promise<void>>()
|
|
1258
|
-
async function withSessionTransition<T>(id: string, body: () => Promise<T>): Promise<T> {
|
|
1259
|
-
const previous = transitionTails.get(id) ?? Promise.resolve()
|
|
1260
|
-
let release!: () => void
|
|
1261
|
-
const current = new Promise<void>((resolve) => { release = resolve })
|
|
1262
|
-
transitionTails.set(id, current)
|
|
1263
|
-
await previous
|
|
1264
|
-
try { return await body() }
|
|
1265
|
-
finally {
|
|
1266
|
-
release()
|
|
1267
|
-
if (transitionTails.get(id) === current) transitionTails.delete(id)
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
let draining = false // re-entrancy guard: only one drain pass runs at a time (no double-launch)
|
|
1271
|
-
|
|
1272
|
-
// launch a prepared `queued` worktree: feed it its parked launch prompt, flip it to active. Returns false
|
|
1273
|
-
// (leaving it queued, to be retried next drain) if the worktree/prompt is gone or the tmux launch threw.
|
|
1274
|
-
async function startQueuedUnlocked(id: string): Promise<boolean> {
|
|
1275
|
-
if (archiving.has(id)) return false
|
|
1276
|
-
const wt = await findWorktree(id)
|
|
1277
|
-
if (!wt) return false
|
|
1278
|
-
if (archiving.has(id) || wt.rec.archived) return false
|
|
1279
|
-
if (!canDrainQueued(wt.rec)) return false
|
|
1280
|
-
const launchPrompt = readLaunchFile(id)
|
|
1281
|
-
if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
|
|
1282
|
-
// a queued worktree can go missing while it waits (a human cleaned up, a disk moved). Draining it would open
|
|
1283
|
-
// a window that fast-exits and burn the retry budget every tick, so refuse ONCE, loudly, and stamp the reason
|
|
1284
|
-
// on the record — the drainer then leaves it alone instead of spinning on a launch that cannot work.
|
|
1285
|
-
const blocked = launchPreflight(wt.rec)
|
|
1286
|
-
if (blocked) {
|
|
1287
|
-
if (wt.rec.note !== blocked.message) {
|
|
1288
|
-
console.error(`spex: not launching queued session ${id}: ${blocked.message}`)
|
|
1289
|
-
writeRecord({ ...wt.rec, note: blocked.message })
|
|
1290
|
-
}
|
|
1291
|
-
return false
|
|
1292
|
-
}
|
|
1293
|
-
launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
|
|
1294
|
-
const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
|
|
1295
|
-
try {
|
|
1296
|
-
const sq = shQuote(launchPrompt)
|
|
1297
|
-
await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec))
|
|
1298
|
-
} catch {
|
|
1299
|
-
launching.delete(id)
|
|
1300
|
-
return false // launch failed → stays `queued`, retried on the next drain tick
|
|
1301
|
-
}
|
|
1302
|
-
// the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
|
|
1303
|
-
// launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
|
|
1304
|
-
// belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
|
|
1305
|
-
// headline precedence stands on.
|
|
1306
|
-
writeRecord({ ...wt.rec, status: 'active', proposal: null, note: null, launchOwner: null })
|
|
1307
|
-
removeLaunchFile(id) // consumed
|
|
1308
|
-
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
1309
|
-
// wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
|
|
1310
|
-
void waitForReady(id, h).finally(() => launching.delete(id))
|
|
1311
|
-
return true
|
|
1312
|
-
}
|
|
1313
|
-
const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
|
|
1314
|
-
|
|
1315
|
-
async function drainQueueUnlocked(): Promise<void> {
|
|
1316
|
-
if (draining) return
|
|
1317
|
-
draining = true
|
|
1318
|
-
try {
|
|
1319
|
-
const cap = maxActive() // read once per drain pass (spexcode.json → env → default); won't shift mid-burst
|
|
1320
|
-
for (;;) {
|
|
1321
|
-
const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()])
|
|
1322
|
-
// if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
|
|
1323
|
-
// session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
|
|
1324
|
-
// MORE compute onto an already-thrashing box. Under load, do the safe thing — launch nothing this pass and
|
|
1325
|
-
// let the next tick re-drain once the probe recovers ([[state]] board honesty applied to the cap).
|
|
1326
|
-
if (snap.probeFailed) break
|
|
1327
|
-
const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0)
|
|
1328
|
-
if (occupied >= cap) break
|
|
1329
|
-
const authority = backendLaunchAuthority()
|
|
1330
|
-
const next = sessions.find((s) => {
|
|
1331
|
-
if (s.status !== 'queued' || launching.has(s.id)) return false
|
|
1332
|
-
const rec = readRecord(s.id)
|
|
1333
|
-
return !!rec && canDrainQueued(rec, authority)
|
|
1334
|
-
})
|
|
1335
|
-
if (!next) break
|
|
1336
|
-
if (!(await startQueued(next.id))) break // launch failed → stop this pass; a later tick retries
|
|
1337
|
-
}
|
|
1338
|
-
} finally { draining = false }
|
|
1339
|
-
}
|
|
1340
|
-
export const drainQueue = (): Promise<void> => drainQueueUnlocked()
|
|
1341
|
-
const requestQueueDrain = (): void => {
|
|
1342
|
-
void drainQueue().catch((error) => {
|
|
1343
|
-
console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
1344
|
-
})
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
let supervisingQueue = false
|
|
1348
|
-
export function superviseQueue(intervalMs = 3000): void {
|
|
1349
|
-
if (supervisingQueue) return
|
|
1350
|
-
supervisingQueue = true
|
|
1351
|
-
const tick = async () => {
|
|
1352
|
-
try { await drainQueue() } catch { /* transient git/tmux hiccup; next tick retries */ }
|
|
1353
|
-
setTimeout(tick, intervalMs)
|
|
1354
|
-
}
|
|
1355
|
-
void tick()
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
let supervisingDelivery = false
|
|
1359
|
-
// @@@ superviseDelivery - the RETRY half of [[delivery-queue]]. `sendText` hands over in its own process, which
|
|
1360
|
-
// covers the live case; this covers everything that could not be handed over then — a harness mid-restart, a
|
|
1361
|
-
// pane in the one state that swallows prompts, a session that was offline when the message arrived. Owned by
|
|
1362
|
-
// the serve that serves this project root, so a message owed to a worker is delivered when the worker can take
|
|
1363
|
-
// it rather than when it happens to run a tool. A tick with nothing owed is one existsSync per session, and
|
|
1364
|
-
// concurrent serves are harmless: the queue's lock, not the process, is what makes a handover exactly-once.
|
|
1365
|
-
export function superviseDelivery(intervalMs = 2000): void {
|
|
1366
|
-
if (supervisingDelivery) return
|
|
1367
|
-
supervisingDelivery = true
|
|
1368
|
-
const tick = async () => {
|
|
1369
|
-
try {
|
|
1370
|
-
for (const id of listSessionIds()) {
|
|
1371
|
-
if (!owesDelivery(id)) continue
|
|
1372
|
-
try { await drainSession(id) } catch { /* an adapter that refused stays owed; next tick retries */ }
|
|
1373
|
-
}
|
|
1374
|
-
} catch { /* transient store read; next tick retries */ }
|
|
1375
|
-
setTimeout(tick, intervalMs).unref()
|
|
1376
|
-
}
|
|
1377
|
-
void tick()
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
type TurnFailureObserverState = {
|
|
1381
|
-
fingerprint: string
|
|
1382
|
-
subscription: FailureSubscription | null
|
|
1383
|
-
startedAt: number
|
|
1384
|
-
failures: number
|
|
1385
|
-
retryAt: number
|
|
1386
|
-
lastReason: string | null
|
|
1387
|
-
}
|
|
1388
|
-
const turnFailureObservers = new Map<string, TurnFailureObserverState>()
|
|
1389
|
-
let supervisingTurnFailures = false
|
|
1390
|
-
const TURN_FAILURE_OBSERVER_STABLE_MS = 5000
|
|
1391
|
-
|
|
1392
|
-
export function turnFailureNote(harness: string, failure: TurnFailure): string {
|
|
1393
|
-
const message = failure.message.replace(/\s+/g, ' ').trim().slice(0, 500) || 'turn failed'
|
|
1394
|
-
const at = failure.completedAt == null ? '' : ` at ${new Date(failure.completedAt * 1000).toISOString()}`
|
|
1395
|
-
return `${harness} turn failed${at}: ${message}`
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
export function turnFailureRetryDelay(failures: number): number {
|
|
1399
|
-
return Math.min(30_000, 1000 * 2 ** Math.max(0, Math.min(failures - 1, 5)))
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
function deferTurnFailureObserver(id: string, harness: string, state: TurnFailureObserverState, reason: string): void {
|
|
1403
|
-
state.subscription = null
|
|
1404
|
-
state.failures++
|
|
1405
|
-
const delay = turnFailureRetryDelay(state.failures)
|
|
1406
|
-
state.retryAt = Date.now() + delay
|
|
1407
|
-
if (state.lastReason !== reason)
|
|
1408
|
-
console.warn(`[spex ${harness}] turn failure observer for ${id} disconnected (${reason}); retrying in ${delay}ms`)
|
|
1409
|
-
state.lastReason = reason
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
// Reconcile one adapter-owned native failure subscription per live governed session. Product code knows only
|
|
1413
|
-
// the optional interface capability; Codex owns WebSocket/thread semantics and Claude keeps using StopFailure.
|
|
1414
|
-
export function reconcileTurnFailureObservers(): void {
|
|
1415
|
-
const wanted = new Map<string, { rec: SessRec; harness: Harness; fingerprint: string }>()
|
|
1416
|
-
for (const id of listSessionIds()) {
|
|
1417
|
-
let rec: SessRec | null = null
|
|
1418
|
-
try { rec = readRecord(id) } catch { continue }
|
|
1419
|
-
if (!rec?.governed || rec.stopped || rec.archived || !rec.harnessSessionId) continue
|
|
1420
|
-
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
1421
|
-
if (!harness.observeTurnFailures) continue
|
|
1422
|
-
wanted.set(id, { rec, harness, fingerprint: `${harness.id}:${rec.harnessSessionId}:${runtimeRoot()}` })
|
|
1423
|
-
}
|
|
1424
|
-
for (const [id, state] of turnFailureObservers) {
|
|
1425
|
-
if (wanted.get(id)?.fingerprint === state.fingerprint) continue
|
|
1426
|
-
turnFailureObservers.delete(id)
|
|
1427
|
-
state.subscription?.close()
|
|
1428
|
-
}
|
|
1429
|
-
for (const [id, target] of wanted) {
|
|
1430
|
-
const now = Date.now()
|
|
1431
|
-
let state = turnFailureObservers.get(id)
|
|
1432
|
-
if (state?.subscription) {
|
|
1433
|
-
if (state.failures > 0 && now - state.startedAt >= TURN_FAILURE_OBSERVER_STABLE_MS) {
|
|
1434
|
-
state.failures = 0
|
|
1435
|
-
state.retryAt = 0
|
|
1436
|
-
state.lastReason = null
|
|
1437
|
-
}
|
|
1438
|
-
continue
|
|
1439
|
-
}
|
|
1440
|
-
if (state && now < state.retryAt) continue
|
|
1441
|
-
state ??= { fingerprint: target.fingerprint, subscription: null, startedAt: 0, failures: 0, retryAt: 0, lastReason: null }
|
|
1442
|
-
state.startedAt = now
|
|
1443
|
-
turnFailureObservers.set(id, state)
|
|
1444
|
-
try {
|
|
1445
|
-
const subscription = target.harness.observeTurnFailures!({
|
|
1446
|
-
session: id,
|
|
1447
|
-
worktreePath: target.rec.worktreePath,
|
|
1448
|
-
harnessSessionId: target.rec.harnessSessionId,
|
|
1449
|
-
runtimeDir: runtimeRoot(),
|
|
1450
|
-
launchCmd: target.rec.launchCmd,
|
|
1451
|
-
}, (failure) => {
|
|
1452
|
-
if (turnFailureObservers.get(id)?.fingerprint !== target.fingerprint) return
|
|
1453
|
-
try { markTurnFailure(id, turnFailureNote(target.harness.id, failure)) }
|
|
1454
|
-
catch (error) { console.error(`[spex ${target.harness.id}] could not record native turn failure for ${id}: ${error instanceof Error ? error.message : String(error)}`) }
|
|
1455
|
-
})
|
|
1456
|
-
state.subscription = subscription
|
|
1457
|
-
void subscription.closed.then((reason) => {
|
|
1458
|
-
if (turnFailureObservers.get(id) !== state) return
|
|
1459
|
-
if (reason) deferTurnFailureObserver(id, target.harness.id, state, reason)
|
|
1460
|
-
else turnFailureObservers.delete(id)
|
|
1461
|
-
})
|
|
1462
|
-
} catch (error) {
|
|
1463
|
-
deferTurnFailureObserver(id, target.harness.id, state, error instanceof Error ? error.message : String(error))
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
export function superviseTurnFailures(intervalMs = 1000): void {
|
|
1469
|
-
if (supervisingTurnFailures) return
|
|
1470
|
-
supervisingTurnFailures = true
|
|
1471
|
-
const tick = () => {
|
|
1472
|
-
try { reconcileTurnFailureObservers() }
|
|
1473
|
-
catch (error) { console.error(`spex: turn failure reconciliation failed: ${error instanceof Error ? error.message : String(error)}`) }
|
|
1474
|
-
const timer = setTimeout(tick, intervalMs)
|
|
1475
|
-
timer.unref?.()
|
|
1476
|
-
}
|
|
1477
|
-
tick()
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
type BackendSettings = { layout?: { main?: string } }
|
|
1481
|
-
type BackendInstance = { root?: unknown }
|
|
1482
|
-
function assertProjectRootMatch(verb: string, target: ApiBaseInfo, servedRoot: string | null): void {
|
|
1483
|
-
const { url, source } = target
|
|
1484
|
-
if (source === 'flag') return // explicitly routed — the caller named the target
|
|
1485
|
-
let localMain: string
|
|
1486
|
-
try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
|
|
1487
|
-
if (!servedRoot || !isAbsolute(servedRoot)) return // unknown / config-aliased root → don't risk a false refusal
|
|
1488
|
-
let backendMain: string
|
|
1489
|
-
try { backendMain = realpathSync(servedRoot) } catch { return } // backend root not a local path → a remote backend, allow
|
|
1490
|
-
if (backendMain !== localMain) {
|
|
1491
|
-
const e = new Error(
|
|
1492
|
-
`${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
|
|
1493
|
-
`Name the target explicitly (--api <url> / --port <n>) to write cross-project on purpose,\n` +
|
|
1494
|
-
`or run this project's own backend: cd ${localMain} && spex serve. (Reads stay unguarded.)`)
|
|
1495
|
-
e.name = 'GuardError'
|
|
1496
|
-
throw e
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
|
|
1500
|
-
assertProjectRootMatch(verb, target, settings?.layout?.main ?? null)
|
|
1501
|
-
}
|
|
1502
|
-
function assertProjectInstanceMatch(verb: string, target: ApiBaseInfo, instance: BackendInstance | null): void {
|
|
1503
|
-
const root = instance?.root
|
|
1504
|
-
if (typeof root !== 'string' || !isAbsolute(root)) return
|
|
1505
|
-
let servedMain: string
|
|
1506
|
-
try { servedMain = mainRoot(root) } catch { return }
|
|
1507
|
-
assertProjectRootMatch(verb, target, servedMain)
|
|
1508
|
-
}
|
|
1509
|
-
export async function assertProjectMatch(verb: string): Promise<void> {
|
|
1510
|
-
const target = await apiBaseInfo()
|
|
1511
|
-
if (target.source === 'flag') return
|
|
1512
|
-
let settings: BackendSettings | null = null
|
|
1513
|
-
try {
|
|
1514
|
-
const r = await fetch(`${target.url}/api/settings`)
|
|
1515
|
-
if (r.ok) settings = await r.json() as BackendSettings
|
|
1516
|
-
} catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
|
|
1517
|
-
assertProjectSettingsMatch(verb, target, settings)
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
export type SessionCreateFailureCode =
|
|
1521
|
-
| 'session_create_timeout'
|
|
1522
|
-
| 'session_create_cancelled'
|
|
1523
|
-
| 'session_create_failed'
|
|
1524
|
-
| 'session_create_cleanup_failed'
|
|
1525
|
-
| 'session_create_key_reused'
|
|
1526
|
-
type SessionCreateFailureStatus = 400 | 408 | 409 | 500 | 504
|
|
1527
|
-
type SessionCreatePhase = 'request' | 'creation-lock' | 'launcher-resolution' | 'target-resolution' | 'git-worktree' | 'materialize' | 'record-write' | 'launcher-queue' | 'cleanup'
|
|
1528
|
-
export class SessionCreateError extends Error {
|
|
1529
|
-
constructor(
|
|
1530
|
-
readonly code: SessionCreateFailureCode,
|
|
1531
|
-
readonly phase: SessionCreatePhase,
|
|
1532
|
-
message: string,
|
|
1533
|
-
readonly status: SessionCreateFailureStatus,
|
|
1534
|
-
) {
|
|
1535
|
-
super(message)
|
|
1536
|
-
this.name = 'SessionCreateError'
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal }
|
|
1540
|
-
type SessionCreateRequestOptions = {
|
|
1541
|
-
requestKey?: string
|
|
1542
|
-
signal?: AbortSignal
|
|
1543
|
-
timeoutMs?: number
|
|
1544
|
-
}
|
|
1545
|
-
export type SessionCreateRequestResult =
|
|
1546
|
-
| { status: 201; session: Session }
|
|
1547
|
-
| { status: SessionCreateFailureStatus; error: string; code?: SessionCreateFailureCode; phase?: SessionCreatePhase }
|
|
1548
|
-
|
|
1549
|
-
const DEFAULT_CREATE_TIMEOUT_MS = 30_000
|
|
1550
|
-
export function sessionCreateTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
1551
|
-
const configured = Number(env.SPEXCODE_SESSION_CREATE_TIMEOUT_MS)
|
|
1552
|
-
return Number.isFinite(configured) ? Math.max(250, Math.min(120_000, Math.floor(configured))) : DEFAULT_CREATE_TIMEOUT_MS
|
|
1553
|
-
}
|
|
1554
|
-
function normalizeCreateKey(raw: string | undefined): string {
|
|
1555
|
-
const key = raw?.trim() || randomUUID()
|
|
1556
|
-
if (key.length > 128 || !/^[\x21-\x7e]+$/.test(key)) {
|
|
1557
|
-
throw new SessionCreateError('session_create_failed', 'request', 'Idempotency-Key must be 1-128 visible ASCII characters', 400)
|
|
1558
|
-
}
|
|
1559
|
-
return key
|
|
1560
|
-
}
|
|
1561
|
-
const digest = (value: string): string => createHash('sha256').update(value).digest('hex')
|
|
1562
|
-
export function sessionIdForCreateKey(key: string): string {
|
|
1563
|
-
const hex = digest(`spexcode-session-create\0${key}`)
|
|
1564
|
-
const uuid = `${hex.slice(0, 12)}4${hex.slice(13, 16)}${((parseInt(hex[16], 16) & 3) | 8).toString(16)}${hex.slice(17)}`
|
|
1565
|
-
return `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20, 32)}`
|
|
1566
|
-
}
|
|
1567
|
-
function traceSessionCreate(id: string, requestDigest: string, phase: SessionCreatePhase, event: 'start' | 'finish' | 'abort' | 'publish', detail?: string): void {
|
|
1568
|
-
console.error(`spex session-create ${JSON.stringify({ ts: new Date().toISOString(), request: requestDigest.slice(0, 12), session: id, phase, event, ...(detail ? { detail } : {}) })}`)
|
|
1569
|
-
}
|
|
1570
|
-
function createAbortError(signal: AbortSignal, phase: SessionCreatePhase): SessionCreateError {
|
|
1571
|
-
const timedOut = signal.reason instanceof SessionCreateError && signal.reason.code === 'session_create_timeout'
|
|
1572
|
-
return new SessionCreateError(
|
|
1573
|
-
timedOut ? 'session_create_timeout' : 'session_create_cancelled',
|
|
1574
|
-
phase,
|
|
1575
|
-
timedOut ? `session creation timed out during ${phase}` : `session creation was cancelled during ${phase}`,
|
|
1576
|
-
timedOut ? 504 : 408,
|
|
1577
|
-
)
|
|
1578
|
-
}
|
|
1579
|
-
function throwIfCreateAborted(signal: AbortSignal, phase: SessionCreatePhase): void {
|
|
1580
|
-
if (signal.aborted) throw createAbortError(signal, phase)
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// The API create boundary accepts one small, closed object shape. Unknown fields fail through this generic
|
|
1584
|
-
// contract before any worktree is made; removed or misspelled inputs never disappear into defaults.
|
|
1585
|
-
export async function sessionCreateRequest(body: unknown, options: SessionCreateRequestOptions = {}): Promise<SessionCreateRequestResult> {
|
|
1586
|
-
if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
|
|
1587
|
-
const input = body as Record<string, unknown>
|
|
1588
|
-
const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
|
|
1589
|
-
if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
|
|
1590
|
-
const prompt = typeof input.prompt === 'string' ? input.prompt : ''
|
|
1591
|
-
if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
|
|
1592
|
-
const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
|
|
1593
|
-
const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
|
|
1594
|
-
let key: string
|
|
1595
|
-
try { key = normalizeCreateKey(options.requestKey) }
|
|
1596
|
-
catch (error) {
|
|
1597
|
-
const failure = error as SessionCreateError
|
|
1598
|
-
return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
|
|
1599
|
-
}
|
|
1600
|
-
const requestDigest = digest(key)
|
|
1601
|
-
const id = sessionIdForCreateKey(key)
|
|
1602
|
-
const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null }))
|
|
1603
|
-
const controller = new AbortController()
|
|
1604
|
-
const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408))
|
|
1605
|
-
if (options.signal?.aborted) cancel()
|
|
1606
|
-
else options.signal?.addEventListener('abort', cancel, { once: true })
|
|
1607
|
-
const timer = setTimeout(() => controller.abort(new SessionCreateError('session_create_timeout', 'request', 'session creation exceeded its deadline', 504)), options.timeoutMs ?? sessionCreateTimeoutMs())
|
|
1608
|
-
timer.unref?.()
|
|
1609
|
-
traceSessionCreate(id, requestDigest, 'request', 'start')
|
|
1610
|
-
try {
|
|
1611
|
-
try {
|
|
1612
|
-
const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
|
|
1613
|
-
traceSessionCreate(id, requestDigest, 'request', 'finish')
|
|
1614
|
-
return { status: 201, session }
|
|
1615
|
-
} catch (error) {
|
|
1616
|
-
const failure = error instanceof SessionCreateError
|
|
1617
|
-
? error
|
|
1618
|
-
: controller.signal.aborted
|
|
1619
|
-
? createAbortError(controller.signal, 'request')
|
|
1620
|
-
: new SessionCreateError('session_create_failed', 'request', String((error as Error).message || error), 400)
|
|
1621
|
-
return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
|
|
1622
|
-
}
|
|
1623
|
-
} finally {
|
|
1624
|
-
clearTimeout(timer)
|
|
1625
|
-
options.signal?.removeEventListener('abort', cancel)
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
function isExplicitConnectionRefused(error: unknown): boolean {
|
|
1630
|
-
if (!error || typeof error !== 'object') return false
|
|
1631
|
-
if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true
|
|
1632
|
-
const errors = (error as { errors?: unknown }).errors
|
|
1633
|
-
if (Array.isArray(errors)) return errors.length > 0 && errors.every(isExplicitConnectionRefused)
|
|
1634
|
-
return isExplicitConnectionRefused((error as { cause?: unknown }).cause)
|
|
1635
|
-
}
|
|
1636
|
-
async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean> {
|
|
1637
|
-
const controller = new AbortController()
|
|
1638
|
-
const timer = setTimeout(() => controller.abort(), 1500)
|
|
1639
|
-
timer.unref?.()
|
|
1640
|
-
let response: Response
|
|
1641
|
-
try {
|
|
1642
|
-
response = await fetch(`${target.url}/api/instance`, { signal: controller.signal })
|
|
1643
|
-
} catch (error) {
|
|
1644
|
-
clearTimeout(timer)
|
|
1645
|
-
if (isExplicitConnectionRefused(error)) return true
|
|
1646
|
-
const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`)
|
|
1647
|
-
failed.name = 'BackendError'
|
|
1648
|
-
Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error })
|
|
1649
|
-
throw failed
|
|
1650
|
-
}
|
|
1651
|
-
try {
|
|
1652
|
-
let instance: BackendInstance | null = null
|
|
1653
|
-
if (response.ok) {
|
|
1654
|
-
try { instance = await response.json() as BackendInstance }
|
|
1655
|
-
catch { /* an HTTP response already established backend authority */ }
|
|
1656
|
-
}
|
|
1657
|
-
assertProjectInstanceMatch('spex session new', target, instance)
|
|
1658
|
-
return false
|
|
1659
|
-
} finally { clearTimeout(timer) }
|
|
1660
|
-
}
|
|
1661
|
-
export async function createSession(prompt: string, launcher?: string): Promise<Session> {
|
|
1662
|
-
const parent = ownSessionId()
|
|
1663
|
-
const requestKey = randomUUID()
|
|
1664
|
-
const target = await apiBaseInfo()
|
|
1665
|
-
const base = target.url
|
|
1666
|
-
const refused = await probeSessionCreateAuthority(target)
|
|
1667
|
-
if (refused) {
|
|
1668
|
-
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
1669
|
-
const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey })
|
|
1670
|
-
if (fallback.status === 201) return fallback.session
|
|
1671
|
-
const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
|
|
1672
|
-
error.name = 'BackendError'
|
|
1673
|
-
throw error
|
|
1674
|
-
}
|
|
1675
|
-
const controller = new AbortController()
|
|
1676
|
-
const timer = setTimeout(() => controller.abort(new Error('backend session-create request timed out')), sessionCreateTimeoutMs() + 5_000)
|
|
1677
|
-
timer.unref?.()
|
|
1678
|
-
let res: Response
|
|
1679
|
-
try {
|
|
1680
|
-
res = await fetch(`${base}/api/sessions`, {
|
|
1681
|
-
method: 'POST',
|
|
1682
|
-
headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
|
|
1683
|
-
body: JSON.stringify({ prompt, parent, launcher }),
|
|
1684
|
-
signal: controller.signal,
|
|
1685
|
-
})
|
|
1686
|
-
} catch (error) {
|
|
1687
|
-
const failed = new Error(`backend session create failed without fallback after admission began: ${error instanceof Error ? error.message : error}`)
|
|
1688
|
-
failed.name = 'BackendError'
|
|
1689
|
-
throw failed
|
|
1690
|
-
} finally { clearTimeout(timer) }
|
|
1691
|
-
if (!res.ok) {
|
|
1692
|
-
const text = await res.text().catch(() => '')
|
|
1693
|
-
let msg = text
|
|
1694
|
-
try { msg = JSON.parse(text).error || text } catch {}
|
|
1695
|
-
const err = new Error(`backend rejected session (${res.status}): ${msg}`)
|
|
1696
|
-
err.name = 'BackendError'
|
|
1697
|
-
throw err
|
|
1698
|
-
}
|
|
1699
|
-
return await res.json() as Session
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
export function spawnerClause(p: SessRec | null): string {
|
|
1703
|
-
if (!p?.worktreePath) return ''
|
|
1704
|
-
const who = p.name || p.title
|
|
1705
|
-
return `\n\nYou were created by session \`${p.session.slice(0, 8)}\`${who ? ` (${who})` : ''}, whose worktree is ${p.worktreePath}` +
|
|
1706
|
-
`${p.branch ? ` on branch \`${p.branch}\`` : ''}. Your own worktree is branched from \`${mainBranch()}\`, so it does NOT contain that ` +
|
|
1707
|
-
`session's uncommitted or unmerged work — a spec node it just created, an edit it hasn't landed. If your task needs anything of theirs, ` +
|
|
1708
|
-
`read it there directly. Read only: never write into another session's worktree.`
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
function sessionCreateFailureRecord(rec: SessRec, error: unknown): SessRec {
|
|
1712
|
-
const msg = error instanceof Error ? error.message : String(error)
|
|
1713
|
-
console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
|
|
1714
|
-
return { ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` }
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
// A materialize failure can leave a tracked contract or .gitignore half-written. Until publication this is
|
|
1718
|
-
// still creation-owned preparation: no worker can have authored work here, so restore HEAD and remove its
|
|
1719
|
-
// untracked artifacts rather than publish a queued record that close must preserve as possibly-user-dirty.
|
|
1720
|
-
// Disable checkout hooks: recovery is not another anchor that may recreate the failed materialization.
|
|
1721
|
-
async function resetFailedMaterializeCandidate(rec: SessRec, signal: AbortSignal): Promise<void> {
|
|
1722
|
-
throwIfCreateAborted(signal, 'materialize')
|
|
1723
|
-
const reset = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'reset', '--hard', '--quiet', 'HEAD'])
|
|
1724
|
-
if (!reset.ok) {
|
|
1725
|
-
const detail = (reset.stderr || reset.stdout || 'git reset failed without diagnostic').trim()
|
|
1726
|
-
throw new SessionCreateError('session_create_failed', 'materialize',
|
|
1727
|
-
`materialize failed and its prepared worktree could not be restored: ${detail}`, 500)
|
|
1728
|
-
}
|
|
1729
|
-
const clean = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'clean', '-fd', '-e', 'spexcode.local.json'])
|
|
1730
|
-
if (clean.ok) return
|
|
1731
|
-
const detail = (clean.stderr || clean.stdout || 'git clean failed without diagnostic').trim()
|
|
1732
|
-
throw new SessionCreateError('session_create_failed', 'materialize',
|
|
1733
|
-
`materialize failed and its prepared worktree could not be restored: ${detail}`, 500)
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
async function materializeSessionCandidate(rec: SessRec, signal: AbortSignal): Promise<SessRec> {
|
|
1737
|
-
throwIfCreateAborted(signal, 'materialize')
|
|
1738
|
-
try {
|
|
1739
|
-
const req = createRequire(join(pkgRoot(), 'package.json'))
|
|
1740
|
-
const tsxImport = req.resolve('tsx/esm')
|
|
1741
|
-
await new Promise<void>((resolvePromise, reject) => {
|
|
1742
|
-
const child = spawn(process.execPath, ['--import', tsxImport, join(pkgRoot(), 'src', 'cli.ts'), 'materialize'], {
|
|
1743
|
-
cwd: rec.worktreePath,
|
|
1744
|
-
env: process.env,
|
|
1745
|
-
detached: true,
|
|
1746
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
1747
|
-
})
|
|
1748
|
-
let stderr = '', settled = false
|
|
1749
|
-
const killTree = () => {
|
|
1750
|
-
if (!child.pid) return
|
|
1751
|
-
try { process.kill(-child.pid, 'SIGKILL') } catch { /* process group already gone */ }
|
|
1752
|
-
try { child.kill('SIGKILL') } catch { /* child already gone */ }
|
|
1753
|
-
}
|
|
1754
|
-
const abort = () => killTree()
|
|
1755
|
-
signal.addEventListener('abort', abort, { once: true })
|
|
1756
|
-
child.stderr.setEncoding('utf8').on('data', (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk })
|
|
1757
|
-
child.once('error', (error) => {
|
|
1758
|
-
if (settled) return
|
|
1759
|
-
settled = true
|
|
1760
|
-
signal.removeEventListener('abort', abort)
|
|
1761
|
-
reject(error)
|
|
1762
|
-
})
|
|
1763
|
-
child.once('close', (code, childSignal) => {
|
|
1764
|
-
if (settled) return
|
|
1765
|
-
settled = true
|
|
1766
|
-
signal.removeEventListener('abort', abort)
|
|
1767
|
-
if (signal.aborted) { reject(createAbortError(signal, 'materialize')); return }
|
|
1768
|
-
if (code === 0) { resolvePromise(); return }
|
|
1769
|
-
reject(new Error(`materialize exited ${childSignal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`))
|
|
1770
|
-
})
|
|
1771
|
-
if (signal.aborted) abort()
|
|
1772
|
-
})
|
|
1773
|
-
return rec
|
|
1774
|
-
} catch (error) {
|
|
1775
|
-
if (signal.aborted || error instanceof SessionCreateError) throw createAbortError(signal, 'materialize')
|
|
1776
|
-
const failed = sessionCreateFailureRecord(rec, error)
|
|
1777
|
-
await resetFailedMaterializeCandidate(rec, signal)
|
|
1778
|
-
return failed
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
type SessionCandidateOwnership = { store: boolean; path: boolean; worktree: boolean; branch: boolean }
|
|
1783
|
-
type SessionCandidateState = { path: boolean; worktree: boolean; branch: boolean }
|
|
1784
|
-
type SessionCandidateStage = 'prepared' | 'git-created' | 'store-created'
|
|
1785
|
-
type SessionCandidateReceipt = {
|
|
1786
|
-
version: 1
|
|
1787
|
-
requestDigest: string
|
|
1788
|
-
payloadHash: string
|
|
1789
|
-
root: string
|
|
1790
|
-
path: string
|
|
1791
|
-
branch: string
|
|
1792
|
-
prestate: { store: false; path: false; worktree: false; branch: false }
|
|
1793
|
-
stage: SessionCandidateStage
|
|
1794
|
-
}
|
|
1795
|
-
type SessionCandidateReceiptRead =
|
|
1796
|
-
| { kind: 'absent' }
|
|
1797
|
-
| { kind: 'invalid'; error: string }
|
|
1798
|
-
| { kind: 'valid'; receipt: SessionCandidateReceipt }
|
|
1799
|
-
|
|
1800
|
-
const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
|
|
1801
|
-
const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
|
|
1802
|
-
const sessionCandidateLockId = (path: string, branch: string) => `create-resource-${digest(`${path}\0${branch}`)}`
|
|
1803
|
-
// The graph watcher uses this private fence to avoid rebuilding the full board while Git is still
|
|
1804
|
-
// registering a session candidate. The receipt is written before `git worktree add` and retired only
|
|
1805
|
-
// after publication or bounded cleanup, so the path names exactly the transaction-owned worktree.
|
|
1806
|
-
export function pendingSessionCreateWorktreePaths(): Set<string> {
|
|
1807
|
-
const paths = new Set<string>()
|
|
1808
|
-
let entries: import('node:fs').Dirent[]
|
|
1809
|
-
try { entries = readdirSync(sessionCandidateReceiptDir(), { withFileTypes: true }) }
|
|
1810
|
-
catch { return paths }
|
|
1811
|
-
for (const entry of entries) {
|
|
1812
|
-
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
|
1813
|
-
try {
|
|
1814
|
-
const value = JSON.parse(readFileSync(join(sessionCandidateReceiptDir(), entry.name), 'utf8')) as Partial<SessionCandidateReceipt>
|
|
1815
|
-
if (typeof value.path === 'string' && value.path && typeof value.stage === 'string') paths.add(resolve(value.path))
|
|
1816
|
-
} catch { /* an in-flight atomic replace is not a candidate path */ }
|
|
1817
|
-
}
|
|
1818
|
-
return paths
|
|
1819
|
-
}
|
|
1820
|
-
function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
|
|
1821
|
-
const path = sessionCandidateReceiptPath(id)
|
|
1822
|
-
if (!existsSync(path)) return { kind: 'absent' }
|
|
1823
|
-
try {
|
|
1824
|
-
const value = JSON.parse(readFileSync(path, 'utf8')) as Partial<SessionCandidateReceipt>
|
|
1825
|
-
const prestate = value.prestate
|
|
1826
|
-
if (value.version !== 1 || typeof value.requestDigest !== 'string' || typeof value.payloadHash !== 'string'
|
|
1827
|
-
|| typeof value.root !== 'string' || typeof value.path !== 'string' || typeof value.branch !== 'string'
|
|
1828
|
-
|| !prestate || prestate.store !== false || prestate.path !== false || prestate.worktree !== false || prestate.branch !== false
|
|
1829
|
-
|| !['prepared', 'git-created', 'store-created'].includes(value.stage as string)) {
|
|
1830
|
-
return { kind: 'invalid', error: `invalid private candidate receipt at ${path}` }
|
|
1831
|
-
}
|
|
1832
|
-
return { kind: 'valid', receipt: value as SessionCandidateReceipt }
|
|
1833
|
-
} catch (error) {
|
|
1834
|
-
return { kind: 'invalid', error: `invalid private candidate receipt at ${path}: ${error instanceof Error ? error.message : error}` }
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
function writeSessionCandidateReceipt(id: string, receipt: SessionCandidateReceipt): void {
|
|
1838
|
-
const dir = sessionCandidateReceiptDir()
|
|
1839
|
-
mkdirSync(dir, { recursive: true })
|
|
1840
|
-
const path = sessionCandidateReceiptPath(id)
|
|
1841
|
-
const tmp = join(dir, `.${id}.${process.pid}.${randomUUID()}.tmp`)
|
|
1842
|
-
try {
|
|
1843
|
-
writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 })
|
|
1844
|
-
renameSync(tmp, path)
|
|
1845
|
-
} finally { rmSync(tmp, { force: true }) }
|
|
1846
|
-
}
|
|
1847
|
-
function retireSessionCandidateReceipt(id: string): boolean {
|
|
1848
|
-
try { rmSync(sessionCandidateReceiptPath(id), { force: true }) } catch { return false }
|
|
1849
|
-
return !existsSync(sessionCandidateReceiptPath(id))
|
|
1850
|
-
}
|
|
1851
|
-
function sessionCandidateReceiptMatches(receipt: SessionCandidateReceipt, context: SessionCreateContext, root: string, path: string, branch: string): boolean {
|
|
1852
|
-
return receipt.requestDigest === context.requestDigest && receipt.payloadHash === context.payloadHash
|
|
1853
|
-
&& receipt.root === root && receipt.path === path && receipt.branch === branch
|
|
1854
|
-
}
|
|
1855
|
-
function publishedSessionCandidateReceiptRetirementFailure(rec: SessRec, root: string): string | null {
|
|
1856
|
-
const durable = readSessionCandidateReceipt(rec.session)
|
|
1857
|
-
if (durable.kind === 'absent') return null
|
|
1858
|
-
if (durable.kind === 'invalid') return durable.error
|
|
1859
|
-
if (!rec.createRequestId || !rec.createPayloadHash || !rec.branch || !rec.worktreePath
|
|
1860
|
-
|| durable.receipt.requestDigest !== rec.createRequestId || durable.receipt.payloadHash !== rec.createPayloadHash
|
|
1861
|
-
|| durable.receipt.root !== root || durable.receipt.path !== rec.worktreePath || durable.receipt.branch !== rec.branch) {
|
|
1862
|
-
return `private candidate receipt at ${sessionCandidateReceiptPath(rec.session)} does not match the published record`
|
|
1863
|
-
}
|
|
1864
|
-
if (!retireSessionCandidateReceipt(rec.session)) return `private candidate receipt remains at ${sessionCandidateReceiptPath(rec.session)}`
|
|
1865
|
-
return readSessionCandidateReceipt(rec.session).kind === 'absent'
|
|
1866
|
-
? null
|
|
1867
|
-
: `private candidate receipt retirement is unproven at ${sessionCandidateReceiptPath(rec.session)}`
|
|
1868
|
-
}
|
|
1869
|
-
async function retirePublishedSessionCandidateReceipt(rec: SessRec, context: SessionCreateContext): Promise<void> {
|
|
1870
|
-
if (!rec.branch || !rec.worktreePath) return
|
|
1871
|
-
if (readSessionCandidateReceipt(rec.session).kind === 'absent') return
|
|
1872
|
-
const root = mainRoot(), path = rec.worktreePath, branch = rec.branch
|
|
1873
|
-
await withRecordLock(sessionCandidateLockId(path, branch), async () => {
|
|
1874
|
-
const failure = publishedSessionCandidateReceiptRetirementFailure(rec, root)
|
|
1875
|
-
if (failure) console.error(`spex: published session ${rec.session.slice(0, 8)} remains the fence for its candidate receipt: ${failure}`)
|
|
1876
|
-
}, context.signal)
|
|
1877
|
-
}
|
|
1878
|
-
async function sessionCandidateState(root: string, path: string, branch: string, signal: AbortSignal): Promise<SessionCandidateState> {
|
|
1879
|
-
const [listed, ref] = await withGitAbortSignal(signal, () => Promise.all([
|
|
1880
|
-
gitTry(['-C', root, 'worktree', 'list', '--porcelain', '-z']),
|
|
1881
|
-
gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
|
|
1882
|
-
]))
|
|
1883
|
-
if (!listed.ok) throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read worktree registry: ${listed.stderr.trim() || listed.failure}`, 500)
|
|
1884
|
-
if (!ref.ok && ref.failure !== 'exit') throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read candidate branch: ${ref.stderr.trim() || ref.failure}`, 500)
|
|
1885
|
-
return { path: existsSync(path), worktree: listed.stdout.split('\0').includes(`worktree ${path}`), branch: ref.ok }
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
async function cleanupSessionCandidate(root: string, id: string, path: string, branch: string, owned: SessionCandidateOwnership): Promise<string[]> {
|
|
1889
|
-
const residues: string[] = []
|
|
1890
|
-
if (owned.store) {
|
|
1891
|
-
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* verified below */ }
|
|
1892
|
-
}
|
|
1893
|
-
const controller = new AbortController()
|
|
1894
|
-
const timer = setTimeout(() => controller.abort(), 10_000)
|
|
1895
|
-
timer.unref?.()
|
|
1896
|
-
try {
|
|
1897
|
-
await withGitAbortSignal(controller.signal, async () => {
|
|
1898
|
-
if (owned.worktree) {
|
|
1899
|
-
const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', path])
|
|
1900
|
-
if (!removed.ok) residues.push(`worktree remove failed: ${removed.stderr.trim() || removed.failure}`)
|
|
1901
|
-
}
|
|
1902
|
-
const afterRemove = await sessionCandidateState(root, path, branch, controller.signal)
|
|
1903
|
-
if (owned.path && afterRemove.path && !afterRemove.worktree) {
|
|
1904
|
-
try { rmSync(path, { recursive: true, force: true }) } catch { /* verified below */ }
|
|
1905
|
-
}
|
|
1906
|
-
const ref = `refs/heads/${branch}`
|
|
1907
|
-
if (owned.branch) {
|
|
1908
|
-
const deleted = await gitTry(['-C', root, 'branch', '-D', branch])
|
|
1909
|
-
if (!deleted.ok) residues.push(`branch delete failed: ${deleted.stderr.trim() || deleted.failure}`)
|
|
1910
|
-
}
|
|
1911
|
-
const state = await sessionCandidateState(root, path, branch, controller.signal)
|
|
1912
|
-
if ((owned.path && state.path) || (owned.worktree && state.worktree)) residues.push(`owned worktree remains at ${path}`)
|
|
1913
|
-
if (owned.branch && state.branch) residues.push(`owned branch remains at ${ref}`)
|
|
1914
|
-
if ((!owned.path && state.path) || (!owned.worktree && state.worktree)) residues.push(`unowned candidate worktree preserved at ${path}`)
|
|
1915
|
-
if (!owned.branch && state.branch) residues.push(`unowned candidate branch preserved at ${ref}`)
|
|
1916
|
-
})
|
|
1917
|
-
} catch (error) {
|
|
1918
|
-
residues.push(`Git cleanup did not settle: ${error instanceof Error ? error.message : error}`)
|
|
1919
|
-
} finally { clearTimeout(timer) }
|
|
1920
|
-
if (owned.store && existsSync(sessionStoreDir(id))) residues.push(`owned session store remains at ${sessionStoreDir(id)}`)
|
|
1921
|
-
return residues
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
function existingCreateReceipt(rec: SessRec): Session {
|
|
1925
|
-
const h = harnessById(rec.harness || defaultHarness.id)
|
|
1926
|
-
if (rec.status === 'queued') return toSession(rec, 'queued', 'offline')
|
|
1927
|
-
const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? PROPOSAL_STATUS[rec.proposal ?? 'nothing'] : rec.status
|
|
1928
|
-
return toSession(rec, status, rec.stopped ? 'offline' : h.headless ? 'online' : 'starting')
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
|
-
async function proveSessionCandidate(path: string, branch: string, signal: AbortSignal): Promise<string | null> {
|
|
1932
|
-
const [top, checkedOut, ref] = await withGitAbortSignal(signal, () => Promise.all([
|
|
1933
|
-
gitTry(['-C', path, 'rev-parse', '--show-toplevel']),
|
|
1934
|
-
gitTry(['-C', path, 'symbolic-ref', '--quiet', '--short', 'HEAD']),
|
|
1935
|
-
gitTry(['-C', mainRoot(), 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
|
|
1936
|
-
]))
|
|
1937
|
-
if (!top.ok || !checkedOut.ok || !ref.ok) return [top.stderr, checkedOut.stderr, ref.stderr].map((value) => value.trim()).filter(Boolean).join('; ') || 'Git identity validation failed'
|
|
1938
|
-
let actualTop = top.stdout.trim()
|
|
1939
|
-
try { actualTop = realpathSync(actualTop) } catch { /* missing path is reported by the comparison */ }
|
|
1940
|
-
let expectedTop = path
|
|
1941
|
-
try { expectedTop = realpathSync(path) } catch { /* missing path is reported by the comparison */ }
|
|
1942
|
-
if (actualTop !== expectedTop) return `worktree top-level is ${actualTop}, expected ${expectedTop}`
|
|
1943
|
-
if (checkedOut.stdout.trim() !== branch) return `worktree checked out ${checkedOut.stdout.trim() || '(detached)'}, expected ${branch}`
|
|
1944
|
-
return null
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
|
|
1948
|
-
const { id, requestDigest, payloadHash, signal } = context
|
|
1949
|
-
let phase: SessionCreatePhase = 'creation-lock'
|
|
1950
|
-
let shouldDrain = false
|
|
1951
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
1952
|
-
try {
|
|
1953
|
-
const receipt = await withRecordLock(id, async () => {
|
|
1954
|
-
const existing = readRecord(id)
|
|
1955
|
-
if (existing) {
|
|
1956
|
-
if (existing.createRequestId !== requestDigest || existing.createPayloadHash !== payloadHash) {
|
|
1957
|
-
throw new SessionCreateError('session_create_key_reused', 'creation-lock', 'Idempotency-Key is already bound to another session-create payload', 409)
|
|
1958
|
-
}
|
|
1959
|
-
await retirePublishedSessionCandidateReceipt(existing, context)
|
|
1960
|
-
return existingCreateReceipt(existing)
|
|
1961
|
-
}
|
|
1962
|
-
|
|
1963
|
-
phase = 'launcher-resolution'
|
|
1964
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
1965
|
-
let chosen: ReturnType<typeof resolveLauncher>
|
|
1966
|
-
let h: Harness
|
|
1967
|
-
let pinned: string
|
|
1968
|
-
try {
|
|
1969
|
-
const lname = launcher ?? defaultLauncher(mainRoot())
|
|
1970
|
-
chosen = resolveLauncher(lname)
|
|
1971
|
-
h = harnessById(chosen.harness)
|
|
1972
|
-
pinned = h.baseCmd(chosen.cmd)
|
|
1973
|
-
} catch (error) {
|
|
1974
|
-
throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400)
|
|
1975
|
-
}
|
|
1976
|
-
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
1977
|
-
|
|
1978
|
-
phase = 'target-resolution'
|
|
1979
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
1980
|
-
throwIfCreateAborted(signal, phase)
|
|
1981
|
-
const rawPrompt = prompt
|
|
1982
|
-
const ref = nodeFromPrompt(rawPrompt)
|
|
1983
|
-
const launchSpecs = ref ? loadSpecsLite() : null
|
|
1984
|
-
const title = ref ? null : titleFromPrompt(rawPrompt)
|
|
1985
|
-
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
1986
|
-
const root = mainRoot()
|
|
1987
|
-
const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`
|
|
1988
|
-
const path = join(root, '.worktrees', slug)
|
|
1989
|
-
const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined
|
|
1990
|
-
const suffix = (spec ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.` : '')
|
|
1991
|
-
+ spawnerClause(parent ? readRecord(parent) : null)
|
|
1992
|
-
let launchPrompt: string
|
|
1993
|
-
try {
|
|
1994
|
-
launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
|
|
1995
|
-
loadedSpecs: launchSpecs ?? undefined,
|
|
1996
|
-
suffix: suffix || undefined,
|
|
1997
|
-
})).text
|
|
1998
|
-
} catch (error) {
|
|
1999
|
-
throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400)
|
|
2000
|
-
}
|
|
2001
|
-
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2002
|
-
|
|
2003
|
-
phase = 'git-worktree'
|
|
2004
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2005
|
-
const resourceLock = sessionCandidateLockId(path, branch)
|
|
2006
|
-
return await withRecordLock(resourceLock, async () => {
|
|
2007
|
-
throwIfCreateAborted(signal, phase)
|
|
2008
|
-
traceSessionCreate(id, requestDigest, phase, 'start', 'candidate-state')
|
|
2009
|
-
let before = await sessionCandidateState(root, path, branch, signal)
|
|
2010
|
-
traceSessionCreate(id, requestDigest, phase, 'finish', 'candidate-state')
|
|
2011
|
-
let storePresent = existsSync(sessionStoreDir(id))
|
|
2012
|
-
const durable = readSessionCandidateReceipt(id)
|
|
2013
|
-
if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
|
|
2014
|
-
if (durable.kind === 'valid') {
|
|
2015
|
-
if (!sessionCandidateReceiptMatches(durable.receipt, context, root, path, branch)) {
|
|
2016
|
-
throw new SessionCreateError('session_create_failed', phase, 'private candidate receipt does not match this create request; preserving candidate resources', 409)
|
|
2017
|
-
}
|
|
2018
|
-
phase = 'cleanup'
|
|
2019
|
-
traceSessionCreate(id, requestDigest, phase, 'start', `recover-${durable.receipt.stage}`)
|
|
2020
|
-
const recovered = await cleanupSessionCandidate(root, id, path, branch, {
|
|
2021
|
-
store: storePresent, path: before.path, worktree: before.worktree, branch: before.branch,
|
|
2022
|
-
})
|
|
2023
|
-
traceSessionCreate(id, requestDigest, phase, recovered.length ? 'abort' : 'finish', recovered.join('; ') || undefined)
|
|
2024
|
-
if (recovered.length) throw new SessionCreateError('session_create_cleanup_failed', phase, `matching candidate recovery left residue: ${recovered.join('; ')}`, 500)
|
|
2025
|
-
before = { path: false, worktree: false, branch: false }
|
|
2026
|
-
storePresent = false
|
|
2027
|
-
phase = 'git-worktree'
|
|
2028
|
-
} else if (storePresent || before.path || before.worktree || before.branch) {
|
|
2029
|
-
const occupied = [storePresent ? `session store ${sessionStoreDir(id)}` : '', before.path ? `path ${path}` : '', before.worktree ? `registered worktree ${path}` : '', before.branch ? `branch ${branch}` : ''].filter(Boolean).join(', ')
|
|
2030
|
-
throw new SessionCreateError('session_create_failed', phase, `session target is already occupied: ${occupied}`, 409)
|
|
2031
|
-
}
|
|
2032
|
-
let candidateReceipt: SessionCandidateReceipt = {
|
|
2033
|
-
version: 1, requestDigest, payloadHash, root, path, branch,
|
|
2034
|
-
prestate: { store: false, path: false, worktree: false, branch: false }, stage: 'prepared',
|
|
2035
|
-
}
|
|
2036
|
-
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
2037
|
-
const owned: SessionCandidateOwnership = { store: false, path: false, worktree: false, branch: false }
|
|
2038
|
-
let gitMutationStarted = false
|
|
2039
|
-
let published = false
|
|
2040
|
-
try {
|
|
2041
|
-
gitMutationStarted = true
|
|
2042
|
-
traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add')
|
|
2043
|
-
const added = await withGitAbortSignal(signal, () => gitTry(
|
|
2044
|
-
['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()],
|
|
2045
|
-
{ extraEnv: DEFER_FOOTPRINT_REFRESH },
|
|
2046
|
-
))
|
|
2047
|
-
traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add')
|
|
2048
|
-
if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
|
|
2049
|
-
if (!added.ok || !existsSync(path)) {
|
|
2050
|
-
throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
|
|
2051
|
-
}
|
|
2052
|
-
candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
|
|
2053
|
-
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
2054
|
-
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2055
|
-
traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state')
|
|
2056
|
-
seedWorktreeHostState(root, path)
|
|
2057
|
-
traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state')
|
|
2058
|
-
|
|
2059
|
-
let rec: SessRec = {
|
|
2060
|
-
session: id, governed: true, worktreePath: path, branch,
|
|
2061
|
-
node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
|
|
2062
|
-
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
2063
|
-
harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
|
|
2064
|
-
launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
|
|
2065
|
-
}
|
|
2066
|
-
owned.store = true
|
|
2067
|
-
const dir = storeDir(id)
|
|
2068
|
-
writeFileSync(join(dir, 'prompt'), rawPrompt)
|
|
2069
|
-
writeFileSync(join(dir, 'launch'), launchPrompt)
|
|
2070
|
-
candidateReceipt = { ...candidateReceipt, stage: 'store-created' }
|
|
2071
|
-
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
2072
|
-
|
|
2073
|
-
phase = 'materialize'
|
|
2074
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2075
|
-
rec = await materializeSessionCandidate(rec, signal)
|
|
2076
|
-
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2077
|
-
|
|
2078
|
-
phase = 'record-write'
|
|
2079
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2080
|
-
throwIfCreateAborted(signal, phase)
|
|
2081
|
-
const gitMismatch = await proveSessionCandidate(path, branch, signal)
|
|
2082
|
-
if (gitMismatch) throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500)
|
|
2083
|
-
throwIfCreateAborted(signal, phase)
|
|
2084
|
-
writeRecord(rec)
|
|
2085
|
-
published = true
|
|
2086
|
-
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root)
|
|
2087
|
-
if (receiptFailure) console.error(`spex: published session ${id.slice(0, 8)} remains the fence for its candidate receipt: ${receiptFailure}`)
|
|
2088
|
-
shouldDrain = true
|
|
2089
|
-
traceSessionCreate(id, requestDigest, phase, 'publish')
|
|
2090
|
-
return toSession(rec, 'queued', 'offline')
|
|
2091
|
-
} catch (error) {
|
|
2092
|
-
if (published) throw error
|
|
2093
|
-
const failurePhase = error instanceof SessionCreateError ? error.phase : phase
|
|
2094
|
-
let ownershipFailure: string | null = null
|
|
2095
|
-
if (gitMutationStarted && !(owned.path && owned.worktree && owned.branch)) {
|
|
2096
|
-
const inspection = new AbortController()
|
|
2097
|
-
const timer = setTimeout(() => inspection.abort(), 10_000)
|
|
2098
|
-
timer.unref?.()
|
|
2099
|
-
try {
|
|
2100
|
-
const after = await sessionCandidateState(root, path, branch, inspection.signal)
|
|
2101
|
-
owned.path ||= !before.path && after.path
|
|
2102
|
-
owned.worktree ||= !before.worktree && after.worktree
|
|
2103
|
-
owned.branch ||= !before.branch && after.branch
|
|
2104
|
-
} catch (inspectionError) {
|
|
2105
|
-
ownershipFailure = `candidate ownership inspection failed: ${inspectionError instanceof Error ? inspectionError.message : inspectionError}`
|
|
2106
|
-
} finally { clearTimeout(timer) }
|
|
2107
|
-
}
|
|
2108
|
-
phase = 'cleanup'
|
|
2109
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2110
|
-
const residues = await cleanupSessionCandidate(root, id, path, branch, owned)
|
|
2111
|
-
if (ownershipFailure) residues.unshift(ownershipFailure)
|
|
2112
|
-
if (!residues.length && !retireSessionCandidateReceipt(id)) residues.push(`private candidate receipt remains at ${sessionCandidateReceiptPath(id)}`)
|
|
2113
|
-
traceSessionCreate(id, requestDigest, phase, residues.length ? 'abort' : 'finish', residues.join('; ') || undefined)
|
|
2114
|
-
if (residues.length) throw new SessionCreateError('session_create_cleanup_failed', phase, `session creation failed and cleanup left residue: ${residues.join('; ')}`, 500)
|
|
2115
|
-
if (signal.aborted) { phase = failurePhase; throw createAbortError(signal, failurePhase) }
|
|
2116
|
-
throw error instanceof SessionCreateError
|
|
2117
|
-
? error
|
|
2118
|
-
: new SessionCreateError('session_create_failed', failurePhase, error instanceof Error ? error.message : String(error), 500)
|
|
2119
|
-
}
|
|
2120
|
-
}, signal)
|
|
2121
|
-
}, signal)
|
|
2122
|
-
traceSessionCreate(id, requestDigest, 'creation-lock', 'finish')
|
|
2123
|
-
if (shouldDrain) {
|
|
2124
|
-
phase = 'launcher-queue'
|
|
2125
|
-
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2126
|
-
requestQueueDrain()
|
|
2127
|
-
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2128
|
-
}
|
|
2129
|
-
return receipt
|
|
2130
|
-
} catch (error) {
|
|
2131
|
-
const failure = signal.aborted
|
|
2132
|
-
? createAbortError(signal, phase)
|
|
2133
|
-
: error instanceof SessionCreateError
|
|
2134
|
-
? error
|
|
2135
|
-
: new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 500)
|
|
2136
|
-
traceSessionCreate(id, requestDigest, failure.phase, 'abort', failure.code)
|
|
2137
|
-
throw failure
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string) => unknown = materialize): void {
|
|
2142
|
-
try {
|
|
2143
|
-
doMaterialize(rec.worktreePath)
|
|
2144
|
-
} catch (e) {
|
|
2145
|
-
writeRecord(sessionCreateFailureRecord(rec, e))
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
|
|
2150
|
-
// waitForReady (slot-hold + resume) waits through a daemon-race retry
|
|
2151
|
-
// instead of returning before a recovering socket
|
|
2152
|
-
const SOCKET_POLL_MS = 200
|
|
2153
|
-
async function waitForReady(id: string, harness: Harness, pending?: SessRec, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<HarnessLaunchReadinessFence | null> {
|
|
2154
|
-
const current = () => {
|
|
2155
|
-
const stored = readRecord(id)
|
|
2156
|
-
const rec = stored && pending
|
|
2157
|
-
? { ...pending, ...stored, stopped: pending.stopped, archived: pending.archived }
|
|
2158
|
-
: stored || pending
|
|
2159
|
-
return rec ? { ...rec, runtimeDir: runtimeRoot() } : null
|
|
2160
|
-
}
|
|
2161
|
-
const deadline = Date.now() + timeoutMs
|
|
2162
|
-
if (harness.launchReady) return harness.launchReady(current, deadline)
|
|
2163
|
-
const genericFence = (): HarnessLaunchReadinessFence => ({
|
|
2164
|
-
proof: Object.freeze({ kind: 'adapter-liveness', harnessId: harness.id, sessionId: id }),
|
|
2165
|
-
validate: async (latest) => {
|
|
2166
|
-
const rec = latest()
|
|
2167
|
-
const snap = await liveSnapshot()
|
|
2168
|
-
return !!rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online'
|
|
2169
|
-
},
|
|
2170
|
-
})
|
|
2171
|
-
for (;;) {
|
|
2172
|
-
const rec = current()
|
|
2173
|
-
const snap = await liveSnapshot() // window + pane probe + live-listener set in one snapshot — all the adapter needs
|
|
2174
|
-
if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online') return genericFence()
|
|
2175
|
-
if (Date.now() >= deadline) return null
|
|
2176
|
-
await new Promise((r) => setTimeout(r, SOCKET_POLL_MS))
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
type ResumeOptions = { force?: boolean; guard?: boolean }
|
|
2181
|
-
|
|
2182
|
-
async function resumeSessionUnlocked(id: string, opts: ResumeOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
2183
|
-
const { force = false, guard = true } = opts
|
|
2184
|
-
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
2185
|
-
try { wt = await findWorktree(id) }
|
|
2186
|
-
catch (e) { if (e instanceof SessionRecordUnusable) return { ok: false, refused: true, error: e.message }; throw e }
|
|
2187
|
-
if (!wt) return { ok: false, error: `no such session ${id}` }
|
|
2188
|
-
// A process that died while validating left an internal candidate behind. This record lock proves no live
|
|
2189
|
-
// resume still owns it. Restore the frozen public original before doing any transport work and require an
|
|
2190
|
-
// explicit retry; stale runtime evidence is never adopted into a fresh launch attempt.
|
|
2191
|
-
if (wt.rec.launchReadinessPending) {
|
|
2192
|
-
writeRecord(restoreLaunchReadinessOriginal(wt.rec))
|
|
2193
|
-
return {
|
|
2194
|
-
ok: false,
|
|
2195
|
-
refused: true,
|
|
2196
|
-
error: `session ${id}: stale launch readiness pending was recovered fail-closed; the exact stopped/offline record was retained. Retry resume.`,
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
const preResume = wt.rec
|
|
2200
|
-
// a retired session (its worktree gone) is terminal, not offline: say so in its own words rather than in the
|
|
2201
|
-
// preflight's, since `close` — not a repair — is what it needs.
|
|
2202
|
-
const retired = retirementReason(wt.rec)
|
|
2203
|
-
if (retired) return { ok: false, refused: true, error: retired }
|
|
2204
|
-
// everything else the transport can settle before opening a window: no branch, no launcher. A launch that
|
|
2205
|
-
// cannot succeed must not be attempted, retried, or given a regenerated launch script.
|
|
2206
|
-
const blocked = launchPreflight(wt.rec)
|
|
2207
|
-
if (blocked) return { ok: false, refused: true, error: blocked.message }
|
|
2208
|
-
const h = harnessById(wt.rec.harness || defaultHarness.id)
|
|
2209
|
-
// An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
|
|
2210
|
-
// it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
|
|
2211
|
-
// resume can never kill a live leaf merely because the record was hidden.
|
|
2212
|
-
const probeRec = wt.rec.archived ? { ...wt.rec, archived: false, stopped: false } : wt.rec
|
|
2213
|
-
const resumeSnap = await liveSnapshot()
|
|
2214
|
-
const lv = h.runtimeOwnership === 'adapter'
|
|
2215
|
-
? (resumeSnap.windows.has(id) ? 'online' : 'offline')
|
|
2216
|
-
: liveness(probeRec, resumeSnap) // FRESH, honest liveness (listener-verified)
|
|
2217
|
-
if (guard && !force && lv === 'online')
|
|
2218
|
-
return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` }
|
|
2219
|
-
if (guard && !force && lv === 'unknown')
|
|
2220
|
-
return { ok: false, refused: true, error: `session ${id}: the liveness probe failed (the box is likely overloaded) — refusing to relaunch since a live worker can't be ruled out. Retry in a moment, or use force to override.` }
|
|
2221
|
-
const wasArchived = wt.rec.archived
|
|
2222
|
-
if (!wasArchived && wt.rec.adapterRecovery) {
|
|
2223
|
-
const recovery = await h.restoreRuntime?.(wt.rec)
|
|
2224
|
-
if (recovery && !recovery.ok) return { ok: false, refused: true, error: `session ${id}: recovery required before resume — ${recovery.reason}` }
|
|
2225
|
-
writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, stopped: true })
|
|
2226
|
-
wt = await findWorktree(id)
|
|
2227
|
-
if (!wt) return { ok: false, error: `session ${id} disappeared during adapter recovery` }
|
|
2228
|
-
}
|
|
2229
|
-
if (wasArchived && (force || lv === 'offline')) {
|
|
2230
|
-
// Make the durable row visible/offline before any adapter unarchive or launch RPC. Any later failure leaves
|
|
2231
|
-
// a retryable unarchived record rather than archived:true with a newly loaded target thread.
|
|
2232
|
-
const pendingRecovery = wt.rec.adapterRecovery || 'restore-runtime-pending'
|
|
2233
|
-
writeRecord({ ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery })
|
|
2234
|
-
const visible = readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery }
|
|
2235
|
-
const restored = await h.restoreRuntime?.(visible)
|
|
2236
|
-
if (restored && !restored.ok) return { ok: false, refused: true, error: `session ${id}: ${restored.reason}` }
|
|
2237
|
-
writeRecord({ ...(readRecord(id) || visible), adapterRecovery: null, coldProof: null })
|
|
2238
|
-
}
|
|
2239
|
-
// proceeding: settle the RESTING lifecycle (a resumed working agent is now idle), then relaunch iff the agent
|
|
2240
|
-
// is CONFIRMED offline (or force — the wedged-but-alive escape). Clear the explicit-stop marker only after
|
|
2241
|
-
// launch has accepted the relaunch; a thrown launch leaves the record truthfully stopped. `starting`/`unknown`
|
|
2242
|
-
// fall through to a metadata-only no-op.
|
|
2243
|
-
// Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
|
|
2244
|
-
// starting -> online launch path recreates the same conversation.
|
|
2245
|
-
const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec
|
|
2246
|
-
const resumed: SessRec = { ...current, archived: false, coldProof: null, status: current.status === 'active' ? 'idle' : current.status, stopped: false }
|
|
2247
|
-
if (force || lv === 'offline') {
|
|
2248
|
-
await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
|
|
2249
|
-
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
|
|
2250
|
-
let readiness: HarnessLaunchReadinessFence | null = null
|
|
2251
|
-
let readinessError = ''
|
|
2252
|
-
try { readiness = await waitForReady(id, h, resumed) }
|
|
2253
|
-
catch (error) { readinessError = error instanceof Error ? error.message : String(error) }
|
|
2254
|
-
if (!readiness) {
|
|
2255
|
-
const failed = readRecord(id) || current
|
|
2256
|
-
writeRecord({ ...failed, ...preResume, launchReadinessPending: null })
|
|
2257
|
-
return {
|
|
2258
|
-
ok: false,
|
|
2259
|
-
refused: true,
|
|
2260
|
-
error: `session ${id}: launch did not become ready${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
|
|
2261
|
-
}
|
|
2262
|
-
}
|
|
2263
|
-
const latest = readRecord(id) || resumed
|
|
2264
|
-
const candidate: SessRec = {
|
|
2265
|
-
...latest,
|
|
2266
|
-
archived: false,
|
|
2267
|
-
coldProof: null,
|
|
2268
|
-
status: latest.status === 'active' ? 'idle' : latest.status,
|
|
2269
|
-
stopped: false,
|
|
2270
|
-
launchReadinessPending: launchReadinessPending(preResume),
|
|
2271
|
-
}
|
|
2272
|
-
writeRecord(candidate)
|
|
2273
|
-
let stillReady = false
|
|
2274
|
-
try { stillReady = await readiness.validate(() => {
|
|
2275
|
-
const stored = readRecord(id)
|
|
2276
|
-
return stored ? { ...stored, runtimeDir: runtimeRoot() } : null
|
|
2277
|
-
}) }
|
|
2278
|
-
catch (error) { readinessError = error instanceof Error ? error.message : String(error) }
|
|
2279
|
-
if (!stillReady) {
|
|
2280
|
-
const failed = readRecord(id) || candidate
|
|
2281
|
-
writeRecord(restoreLaunchReadinessOriginal(failed))
|
|
2282
|
-
return {
|
|
2283
|
-
ok: false,
|
|
2284
|
-
refused: true,
|
|
2285
|
-
error: `session ${id}: launch readiness changed across the pending publication${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
|
|
2286
|
-
}
|
|
2287
|
-
}
|
|
2288
|
-
const published = readRecord(id) || candidate
|
|
2289
|
-
writeRecord({ ...published, launchReadinessPending: null })
|
|
2290
|
-
} else writeRecord(resumed)
|
|
2291
|
-
return { ok: true }
|
|
2292
|
-
}
|
|
2293
|
-
export const resumeSession = (id: string, opts: ResumeOptions = {}) =>
|
|
2294
|
-
withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, opts)))
|
|
2295
|
-
|
|
2296
|
-
export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
|
|
2297
|
-
const id = opts.sessionId || ownSessionId()
|
|
2298
|
-
if (!id) return false
|
|
2299
|
-
return withRecordLockSync(id, () => {
|
|
2300
|
-
const rec = readLiveRecord(id)
|
|
2301
|
-
if (!rec) return false
|
|
2302
|
-
writeRecord({
|
|
2303
|
-
...rec, status,
|
|
2304
|
-
proposal: status === 'awaiting' ? (opts.proposal ?? 'nothing') : null,
|
|
2305
|
-
note: opts.note ?? null,
|
|
2306
|
-
})
|
|
2307
|
-
return true
|
|
2308
|
-
})
|
|
2309
|
-
}
|
|
2310
|
-
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
|
|
2311
|
-
export const markError = (sessionId?: string) => markState('error', { sessionId })
|
|
2312
|
-
export function markTurnFailure(sessionId: string | undefined, note: string): boolean {
|
|
2313
|
-
if (!sessionId) return false
|
|
2314
|
-
return withRecordLockSync(sessionId, () => {
|
|
2315
|
-
const rec = readLiveRecord(sessionId)
|
|
2316
|
-
if (!rec || rec.status !== 'active' || rec.stopped || rec.archived) return false
|
|
2317
|
-
writeRecord({ ...rec, status: 'error', proposal: null, note })
|
|
2318
|
-
return true
|
|
2319
|
-
})
|
|
2320
|
-
}
|
|
2321
|
-
export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
|
|
2322
|
-
if (exitCode === '0') return false
|
|
2323
|
-
const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`
|
|
2324
|
-
return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`)
|
|
2325
|
-
}
|
|
2326
|
-
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
2327
|
-
const id = sessionId || ownSessionId()
|
|
2328
|
-
if (!id || !harnessSessionId) return false
|
|
2329
|
-
return withRecordLockSync(id, () => {
|
|
2330
|
-
const rec = readLiveRecord(id)
|
|
2331
|
-
if (!rec) return false
|
|
2332
|
-
if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
|
|
2333
|
-
throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`)
|
|
2334
|
-
const codex = rec.harness === 'codex' || rec.harness === 'codex-headless'
|
|
2335
|
-
const root = runtimeRoot()
|
|
2336
|
-
let priorBinding: ReturnType<typeof codexGenerationBindingForSession> = null
|
|
2337
|
-
let generationId: string | undefined
|
|
2338
|
-
let registrationPrepared = false
|
|
2339
|
-
if (codex) {
|
|
2340
|
-
generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim()
|
|
2341
|
-
const ledger = readCodexGenerationLedger(root)
|
|
2342
|
-
if (ledger.revision > 0 && !generationId) throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`)
|
|
2343
|
-
priorBinding = codexGenerationBindingForSession(root, id)
|
|
2344
|
-
if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
|
|
2345
|
-
throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`)
|
|
2346
|
-
if (generationId && !priorBinding) {
|
|
2347
|
-
prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId)
|
|
2348
|
-
registrationPrepared = true
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
try {
|
|
2352
|
-
writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
|
|
2353
|
-
} catch (error) {
|
|
2354
|
-
if (codex && generationId && registrationPrepared) {
|
|
2355
|
-
try {
|
|
2356
|
-
bindCodexGeneration(root, id, harnessSessionId, null)
|
|
2357
|
-
} catch (rollback) {
|
|
2358
|
-
throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`)
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
2361
|
-
throw error
|
|
2362
|
-
}
|
|
2363
|
-
if (codex && generationId) commitCodexGenerationRegistration(root, id, harnessSessionId, generationId)
|
|
2364
|
-
return true
|
|
2365
|
-
})
|
|
2366
|
-
}
|
|
2367
|
-
export function markIdle(sessionId?: string): boolean {
|
|
2368
|
-
const id = sessionId || ownSessionId()
|
|
2369
|
-
if (!id) return false
|
|
2370
|
-
return withRecordLockSync(id, () => {
|
|
2371
|
-
const rec = readLiveRecord(id)
|
|
2372
|
-
if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
|
|
2373
|
-
writeRecord({ ...rec, status: 'idle' })
|
|
2374
|
-
return true
|
|
2375
|
-
})
|
|
2376
|
-
}
|
|
2377
|
-
export function mergeReadiness(proposal: 'merge' | 'nothing' = 'merge'): { ready: boolean; reason?: string } {
|
|
2378
|
-
let dirty: string[] = []
|
|
2379
|
-
try {
|
|
2380
|
-
dirty = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean).map(porcelainPath)
|
|
2381
|
-
} catch { /* git status failed — fall through to the ahead check, still a real guard */ }
|
|
2382
|
-
if (dirty.length) {
|
|
2383
|
-
const shown = dirty.slice(0, 8).join(', ') + (dirty.length > 8 ? ', …' : '')
|
|
2384
|
-
return { ready: false, reason: `uncommitted changes on your node branch (${shown}) — commit your spec+code first` }
|
|
2385
|
-
}
|
|
2386
|
-
// a `nothing` proposal makes no claim about having something to land, so the clean tree is the whole gate.
|
|
2387
|
-
if (proposal === 'nothing') return { ready: true }
|
|
2388
|
-
let ahead = 0
|
|
2389
|
-
const base = mainBranch()
|
|
2390
|
-
try { ahead = Number(git(['rev-list', '--count', `${base}..HEAD`]).trim()) || 0 } catch { ahead = 0 }
|
|
2391
|
-
if (ahead === 0) return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge (declaring \`done --propose nothing\` needs no commits ahead; use it to pause for the human)` }
|
|
2392
|
-
return { ready: true }
|
|
2393
|
-
}
|
|
2394
|
-
|
|
2395
|
-
// the path a `git status --porcelain` line refers to: strip the `XY ` status, and for a rename keep the
|
|
2396
|
-
// NEW path (after ` -> `). Shared by the dirty-file counters (mergeReadiness above, reviewPayload below).
|
|
2397
|
-
function porcelainPath(line: string): string {
|
|
2398
|
-
let p = line.slice(3)
|
|
2399
|
-
const arrow = p.indexOf(' -> '); if (arrow >= 0) p = p.slice(arrow + 4)
|
|
2400
|
-
return p
|
|
2401
|
-
}
|
|
2402
|
-
|
|
2403
|
-
export type ReviewEvalFacts = { freshPass: number; freshFail: number; needReview: number; blind: number }
|
|
2404
|
-
export type ReviewEvalGate = ({ phase: 'ready' } & ReviewEvalFacts) | { phase: 'unavailable' | 'loading' | 'updating' | 'error' }
|
|
2405
|
-
// the session-side gates only. The measured-loss readout is composed ABOVE this layer ([[manager-cockpit]]'s
|
|
2406
|
-
// cockpit.ts): the eval package imports this module, so reading it from here could only ever be a deferred
|
|
2407
|
-
// import working around a cycle. The eval side never consumed this field — it reads lint/conflict/ahead/dirty.
|
|
2408
|
-
export type ReviewGates = {
|
|
2409
|
-
conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
|
|
2410
|
-
lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
|
|
2411
|
-
}
|
|
2412
|
-
export type ReviewPayload = {
|
|
2413
|
-
id: string; node: string | null; branch: string | null
|
|
2414
|
-
label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
|
|
2415
|
-
ahead: number // commits the node branch is ahead of main
|
|
2416
|
-
dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
|
|
2417
|
-
diff: ReviewDiffFile[] // the worker's real changes, anchored at the merge-base
|
|
2418
|
-
gates: ReviewGates
|
|
2419
|
-
proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
|
-
// @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
|
|
2423
|
-
// (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
|
|
2424
|
-
// it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
|
|
2425
|
-
// wasteful, so memoize it on a whole-repo fingerprint: `rev-parse HEAD` + `status --porcelain` + the mtimes of
|
|
2426
|
-
// the changed paths (covers committed state, the dirty SET, and dirty-file CONTENT). An identical fingerprint
|
|
2427
|
-
// reuses the last (in-flight) result — a re-open or a second session's proof is instant — while any commit or
|
|
2428
|
-
// working-tree edit moves the fingerprint and recomputes. A rejected run is not cached.
|
|
2429
|
-
let gateCache: { fp: string; p: Promise<ReviewGates['lint']> } | null = null
|
|
2430
|
-
async function lintGate(): Promise<ReviewGates['lint']> {
|
|
2431
|
-
const root = repoRoot()
|
|
2432
|
-
const [head, status] = await Promise.all([
|
|
2433
|
-
gitA(['-C', root, 'rev-parse', 'HEAD']),
|
|
2434
|
-
gitA(['-C', root, 'status', '--porcelain', '--untracked-files=all']),
|
|
2435
|
-
])
|
|
2436
|
-
// `status --porcelain` gives the SET of changed paths + status letters but is CONTENT-BLIND: re-editing an
|
|
2437
|
-
// already-listed (dirty or untracked) file leaves the string byte-identical, so HEAD+status alone would
|
|
2438
|
-
// freeze the gate after a file first goes dirty. `--untracked-files=all` stops an untracked dir from
|
|
2439
|
-
// collapsing to one line (which hides a newly-added file); then fold each listed path's mtime in, so a
|
|
2440
|
-
// content edit to a dirty file also moves the fingerprint. HEAD covers committed state, this covers the
|
|
2441
|
-
// working tree. (Residual, accepted: the fingerprint is snapshot just before the compute, so a change
|
|
2442
|
-
// landing mid-compute is labelled with the pre-change fp — rare, and the gate is advisory, re-verified at merge.)
|
|
2443
|
-
const mtimes = status.split('\n').filter(Boolean).map(porcelainPath)
|
|
2444
|
-
.map((p) => { try { return statSync(join(root, p)).mtimeMs } catch { return 0 } }).join(',')
|
|
2445
|
-
const fp = head.trim() + '\n' + status + '\n' + mtimes
|
|
2446
|
-
if (gateCache?.fp === fp) return gateCache.p
|
|
2447
|
-
const p = (async () => {
|
|
2448
|
-
const { specLint } = await import('./lint.js')
|
|
2449
|
-
const findings = await specLint()
|
|
2450
|
-
return {
|
|
2451
|
-
errorCount: findings.filter((f) => f.level === 'error').length,
|
|
2452
|
-
warningCount: findings.filter((f) => f.level === 'warn').length,
|
|
2453
|
-
}
|
|
2454
|
-
})()
|
|
2455
|
-
p.catch(() => { if (gateCache?.p === p) gateCache = null }) // don't pin a failed run
|
|
2456
|
-
gateCache = { fp, p }
|
|
2457
|
-
return p
|
|
2458
|
-
}
|
|
2459
|
-
|
|
2460
|
-
// @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
|
|
2461
|
-
// (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
|
|
2462
|
-
// in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
|
|
2463
|
-
// so an unchanged tree doesn't re-run the lint on each review / Proof-tab open, while any commit or edit
|
|
2464
|
-
// invalidates and recomputes.
|
|
2465
|
-
export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
2466
|
-
const wt = await findWorktree(id)
|
|
2467
|
-
if (!wt) return null
|
|
2468
|
-
const base = mainBranch()
|
|
2469
|
-
const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
|
|
2470
|
-
gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
|
|
2471
|
-
gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
|
|
2472
|
-
mergeBaseDiff(wt.path, base),
|
|
2473
|
-
mergeConflicts(wt.path, base),
|
|
2474
|
-
lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
|
|
2475
|
-
])
|
|
2476
|
-
// the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
|
|
2477
|
-
// path is genuine work — this is just the total uncommitted count.
|
|
2478
|
-
const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
|
|
2479
|
-
return {
|
|
2480
|
-
id, node: wt.rec.node, branch: wt.branch,
|
|
2481
|
-
label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
|
|
2482
|
-
ahead: Number(aheadOut.trim()) || 0,
|
|
2483
|
-
dirtyNonRuntime, diff,
|
|
2484
|
-
gates: { conflictsWithMain, lint },
|
|
2485
|
-
proposal: { kind: wt.rec.proposal, note: wt.rec.note },
|
|
2486
|
-
}
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
|
-
function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
2490
|
-
const base = mainBranch()
|
|
2491
|
-
return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself — in YOUR OWN worktree, never in the shared ${base} checkout.\n\n` +
|
|
2492
|
-
`1. Sync first, where you work: \`git merge ${base}\` INTO your branch, resolve every conflict here, and re-run what proves your work. The ${base} checkout is the fleet's ONE landing door — a merge that stops to ask about conflicts holds it for everyone.\n` +
|
|
2493
|
-
`2. Land only a TRIVIAL merge: \`git -C ${mainPath} merge-base --is-ancestor ${base} ${branch}\` must exit 0 (your branch already contains ${base}) — then\n git -C ${mainPath} merge --no-ff -m "merge ${branch}: ${reason}" ${branch}\n If that check fails, ${base} moved while you tested: go back to step 1 instead of landing.\n` +
|
|
2494
|
-
`3. A busy door is a wait, not a race: if the ${base} checkout is already mid-merge (an unresolved index), retry with a bounded wait — never abort or resolve someone else's in-progress merge. ` +
|
|
2495
|
-
`4. Verify it landed: \`${base}\`'s HEAD must now be the new merge commit and no merge may be left in progress — if YOUR merge went half-merged, run \`git -C ${mainPath} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
|
|
2496
|
-
`5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean; reason?: string }> {
|
|
2500
|
-
const wt = await findWorktree(id)
|
|
2501
|
-
if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
|
|
2502
|
-
const branch = wt.branch, main = mainRoot()
|
|
2503
|
-
// ensure-live, NOT the guarded human relaunch: an already-online agent is reused (the merge prompt just needs
|
|
2504
|
-
// a live socket), and only a confirmed-offline one is relaunched — so merge never refuses on a live agent.
|
|
2505
|
-
const re = await resumeSession(id, { guard: false })
|
|
2506
|
-
if (!re.ok) return { dispatched: false, reason: re.error || 'could not resume session' }
|
|
2507
|
-
const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
|
|
2508
|
-
const reason = subject.replace(/^spec:\s+/, '') || branch
|
|
2509
|
-
const r = await sendText(id, mergePrompt(main, branch, reason))
|
|
2510
|
-
if (!r.ok) return { dispatched: false, reason: r.error }
|
|
2511
|
-
return { dispatched: true }
|
|
2512
|
-
}
|
|
2513
|
-
export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
|
|
2514
|
-
mergeSessionUnlocked(id)
|
|
2515
|
-
|
|
2516
|
-
// @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
|
|
2517
|
-
// process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
|
|
2518
|
-
// tmux server and keep running, orphaned, still holding its rendezvous socket (measured: pane gone, server
|
|
2519
|
-
// gone, agent still answering). Close promises ZERO residue including the process tree, so the teardown
|
|
2520
|
-
// escalates on the pid launch registered for exactly this purpose: give the SIGHUP its moment, then SIGTERM,
|
|
2521
|
-
// then SIGKILL, each bounded. This is also what lets the socket sweep run at all — a still-answering listener
|
|
2522
|
-
// is never ours to unlink, so an un-killed agent would otherwise strand its own socket forever.
|
|
2523
|
-
// The escalation is IDENTITY-GUARDED: a recorded pid can have been recycled by an unrelated process, so we
|
|
2524
|
-
// signal only a pid whose argv still names THIS session. Unidentifiable → we signal nothing and let the
|
|
2525
|
-
// adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
|
|
2526
|
-
const AGENT_EXIT_GRACE_MS = 3000
|
|
2527
|
-
type LeafIdentity = { pid: number; startToken: string; ownerNeedle: string }
|
|
2528
|
-
async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, leaf: LeafIdentity): Promise<void> {
|
|
2529
|
-
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2530
|
-
if (pid !== leaf.pid)
|
|
2531
|
-
throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
|
|
2532
|
-
if (!Number.isFinite(pid) || pid <= 0) return
|
|
2533
|
-
const startToken = leaf.startToken
|
|
2534
|
-
const alive = (): boolean => leafAlive(pid)
|
|
2535
|
-
const identityState = (): 'same' | 'gone' | 'changed' => {
|
|
2536
|
-
if (readAgentPid(sessionArtifactPath(id, 'agent.pid')) !== leaf.pid) return 'changed'
|
|
2537
|
-
const current = processStartToken(pid)
|
|
2538
|
-
if (!current) return alive() ? 'changed' : 'gone'
|
|
2539
|
-
return current === startToken ? 'same' : 'changed'
|
|
2540
|
-
}
|
|
2541
|
-
const initialState = identityState()
|
|
2542
|
-
if (initialState === 'gone') return
|
|
2543
|
-
if (initialState === 'changed') throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
|
|
2544
|
-
const gone = async (ms: number): Promise<boolean> => {
|
|
2545
|
-
for (const end = Date.now() + ms; Date.now() < end;) {
|
|
2546
|
-
if (!alive()) return true
|
|
2547
|
-
await new Promise((r) => setTimeout(r, 100))
|
|
2548
|
-
}
|
|
2549
|
-
return !alive()
|
|
2550
|
-
}
|
|
2551
|
-
if (await gone(AGENT_EXIT_GRACE_MS)) return // the pane's SIGHUP took it — the normal path
|
|
2552
|
-
const sameAgentInstance = async (): Promise<boolean> => {
|
|
2553
|
-
if (processStartToken(pid) !== startToken) return false
|
|
2554
|
-
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
|
|
2555
|
-
return argv.includes(leaf.ownerNeedle) && processStartToken(pid) === startToken
|
|
2556
|
-
}
|
|
2557
|
-
for (const sig of ['SIGTERM', 'SIGKILL'] as const) {
|
|
2558
|
-
await beforeSignal()
|
|
2559
|
-
const state = identityState()
|
|
2560
|
-
if (state === 'gone') return
|
|
2561
|
-
if (state === 'changed') throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed during escalation`)
|
|
2562
|
-
if (!await sameAgentInstance()) throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} no longer proves ownership`)
|
|
2563
|
-
try { process.kill(pid, sig) } catch { return } // vanished between checks
|
|
2564
|
-
if (await gone(sig === 'SIGTERM' ? AGENT_EXIT_GRACE_MS : 1000)) return
|
|
2565
|
-
}
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
// @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
|
|
2569
|
-
// two: kill the agent's tmux client, make sure the agent itself actually went with it, drop its boot-window
|
|
2570
|
-
// stamp (else a just-launched id lingers in the grace window reading `starting` instead of `offline`), and ask
|
|
2571
|
-
// the resolved adapter to sweep its ephemeral runtime transport — in that order, because the adapter only
|
|
2572
|
-
// removes a transport whose listener is PROVEN dead.
|
|
2573
|
-
// Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
|
|
2574
|
-
// @@@ leafAlive - does this pid name a live process? EPERM counts as alive (a process we may not signal is
|
|
2575
|
-
// still a process); only ESRCH is absence. Kept local: git.ts carries its own copy for lock reclamation, and
|
|
2576
|
-
// collapsing the two is part of the spec/eval unification lane, not of this fix.
|
|
2577
|
-
const leafAlive = (pid: number): boolean => {
|
|
2578
|
-
try { process.kill(pid, 0); return true }
|
|
2579
|
-
catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
|
|
2580
|
-
}
|
|
2581
|
-
|
|
2582
|
-
async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
|
|
2583
|
-
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2584
|
-
if (harness.runtimeOwnership === 'adapter') return null
|
|
2585
|
-
// Prove the exact per-session leaf before the first tmux signal. Missing identity is acceptable only for a
|
|
2586
|
-
// record-only/queued runtime; a live leaf without a matching pid/start/argv stays visible.
|
|
2587
|
-
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2588
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
2589
|
-
if (rec.stopped || rec.status === 'queued') return null
|
|
2590
|
-
throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`)
|
|
2591
|
-
}
|
|
2592
|
-
const startToken = processStartToken(pid)
|
|
2593
|
-
if (!startToken) {
|
|
2594
|
-
if (rec.stopped || !leafAlive(pid)) return null
|
|
2595
|
-
throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is alive but will not prove its start identity`)
|
|
2596
|
-
}
|
|
2597
|
-
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
|
|
2598
|
-
const ownerNeedle = harness.leafOwnerNeedle?.(rec)
|
|
2599
|
-
if (!ownerNeedle)
|
|
2600
|
-
throw new ResourceConflict(`refusing to stop ${id}: no exact harness identity is registered for leaf PID ${pid}`)
|
|
2601
|
-
if (!argv.includes(ownerNeedle) || processStartToken(pid) !== startToken)
|
|
2602
|
-
throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} does not prove argv ownership`)
|
|
2603
|
-
return { pid, startToken, ownerNeedle }
|
|
2604
|
-
}
|
|
2605
|
-
|
|
2606
|
-
async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false, coldReceipt?: unknown): Promise<void> {
|
|
2607
|
-
// The caller resolves one readable owner before entering this seam. An absent/corrupt record never reaches
|
|
2608
|
-
// tmux, signals, or adapter cleanup: a bare session id is an address, not ownership authority.
|
|
2609
|
-
const assertOwned = () => assertSessionStopSafe(id, rec ? { ...rec, harness: rec.harness } : null,
|
|
2610
|
-
{ ...(requireCold && coldReceipt !== undefined ? { coldReceipt } : {}) })
|
|
2611
|
-
await assertOwned()
|
|
2612
|
-
if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session owner`)
|
|
2613
|
-
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2614
|
-
const leaf = await assertSessionLeafOwned(id, rec)
|
|
2615
|
-
// Adapter-owned headless sessions may have no live leaf PID, but launch still created an exact tmux session
|
|
2616
|
-
// wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the PID/argv proof, never the
|
|
2617
|
-
// exact tmux teardown.
|
|
2618
|
-
await tmuxOk(['kill-session', '-t', id])
|
|
2619
|
-
if (leaf) await killAgentProcess(id, assertOwned, leaf)
|
|
2620
|
-
launchedAt.delete(id)
|
|
2621
|
-
await harness.cleanupRuntime(rec)
|
|
2622
|
-
if (requireCold) {
|
|
2623
|
-
const cold = await harness.coldRuntime?.(rec, coldReceipt)
|
|
2624
|
-
if (cold && !cold.ok) throw new ResourceConflict(`refusing to archive ${id}: ${cold.reason}`)
|
|
2625
|
-
}
|
|
2626
|
-
}
|
|
2627
|
-
|
|
2628
|
-
async function stopSessionUnlocked(id: string): Promise<boolean> {
|
|
2629
|
-
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
2630
|
-
try { wt = await findWorktree(id) }
|
|
2631
|
-
catch (e) {
|
|
2632
|
-
if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
|
|
2633
|
-
await stopAgentProcess(id, null)
|
|
2634
|
-
throw e
|
|
2635
|
-
}
|
|
2636
|
-
if (!wt) return false
|
|
2637
|
-
await stopAgentProcess(id, wt.rec)
|
|
2638
|
-
const rec = readRecord(id)
|
|
2639
|
-
if (rec) writeRecord({ ...rec, stopped: true })
|
|
2640
|
-
requestQueueDrain() // a stop frees a slot — start the next queued session if any
|
|
2641
|
-
return !!wt
|
|
2642
|
-
}
|
|
2643
|
-
export const stopSession = (id: string): Promise<boolean> =>
|
|
2644
|
-
withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)))
|
|
2645
|
-
|
|
2646
|
-
async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
2647
|
-
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
2648
|
-
try { wt = await findWorktree(id) }
|
|
2649
|
-
catch (e) {
|
|
2650
|
-
if (e instanceof SessionRecordUnusable) throw new ResourceConflict(`refusing to archive ${id}: ${e.message}`)
|
|
2651
|
-
throw e
|
|
2652
|
-
}
|
|
2653
|
-
if (!wt) return false
|
|
2654
|
-
if (!on) throw new ResourceConflict('unarchive is not a record-only transition; use resume to restore the runtime')
|
|
2655
|
-
if (wt.rec.status === 'queued') throw new ResourceConflict(`refusing to archive ${id}: queued sessions have only a prepared launch prompt; resume/startQueued is their lifecycle`)
|
|
2656
|
-
const retired = retirementReason(wt.rec)
|
|
2657
|
-
if (retired) throw new ResourceConflict(`refusing to archive ${id}: ${retired}`)
|
|
2658
|
-
archiving.add(id)
|
|
2659
|
-
try {
|
|
2660
|
-
const h = harnessById(wt.rec.harness || defaultHarness.id)
|
|
2661
|
-
const settleArchiveRecovery = () => {
|
|
2662
|
-
const current = readRecord(id)
|
|
2663
|
-
if (current?.adapterRecovery) writeRecord({ ...current, adapterRecovery: null })
|
|
2664
|
-
}
|
|
2665
|
-
// A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
|
|
2666
|
-
// adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
|
|
2667
|
-
if (wt.rec.archived && hasValidColdProof(wt.rec)) {
|
|
2668
|
-
const proofSnap = await liveSnapshot()
|
|
2669
|
-
if (proofSnap.probeFailed) throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`)
|
|
2670
|
-
const proofLv = h.runtimeOwnership === 'adapter'
|
|
2671
|
-
? (proofSnap.windows.has(id) ? 'online' : 'offline')
|
|
2672
|
-
: liveness({ ...wt.rec, archived: false, stopped: false }, proofSnap)
|
|
2673
|
-
if (proofLv === 'unknown' || proofLv === 'starting') throw new ResourceConflict(`refusing to re-archive ${id}: session liveness is ${proofLv}; exact cold state is unproven`)
|
|
2674
|
-
if (proofLv === 'offline') {
|
|
2675
|
-
// A deliberately stopped shared control plane is a valid empty resident census. A durable proof plus
|
|
2676
|
-
// an adapter-owned root-absent fact is the only idempotent short-circuit; a live root still has to prove
|
|
2677
|
-
// the thread's archived/non-archived disk collection before we can claim it is already cold.
|
|
2678
|
-
const rootAbsent = await Promise.all((h.sharedRuntimes?.(runtimeRoot()) ?? []).map(async (descriptor) => {
|
|
2679
|
-
if (!descriptor.residency) return false
|
|
2680
|
-
const state = await descriptor.residency()
|
|
2681
|
-
return state.healthy && state.rootAbsent === true && state.referenceIds.length === 0
|
|
2682
|
-
})).then((states) => states.some(Boolean))
|
|
2683
|
-
if (rootAbsent) {
|
|
2684
|
-
settleArchiveRecovery()
|
|
2685
|
-
return true
|
|
2686
|
-
}
|
|
2687
|
-
const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true })
|
|
2688
|
-
if (!pre || pre.ok) {
|
|
2689
|
-
const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true }, pre?.ok ? pre.receipt : undefined)
|
|
2690
|
-
if (!cold || cold.ok) {
|
|
2691
|
-
settleArchiveRecovery()
|
|
2692
|
-
return true
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
}
|
|
2696
|
-
}
|
|
2697
|
-
// Legacy/respawned archives are made visible before repair. Any refusal below therefore leaves an unarchived
|
|
2698
|
-
// row instead of relying on a hidden bit while a runtime proof is missing.
|
|
2699
|
-
if (wt.rec.archived) {
|
|
2700
|
-
writeRecord({ ...wt.rec, archived: false, coldProof: null })
|
|
2701
|
-
wt = await findWorktree(id)
|
|
2702
|
-
if (!wt) return false
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
const snap = await liveSnapshot()
|
|
2706
|
-
if (snap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`)
|
|
2707
|
-
const lv = h.runtimeOwnership === 'adapter'
|
|
2708
|
-
? 'offline'
|
|
2709
|
-
: liveness({ ...wt.rec, archived: false, stopped: false }, snap)
|
|
2710
|
-
if (lv === 'unknown' || lv === 'starting')
|
|
2711
|
-
throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
|
|
2712
|
-
// The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
|
|
2713
|
-
// ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
|
|
2714
|
-
const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
|
|
2715
|
-
if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
|
|
2716
|
-
// Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
|
|
2717
|
-
// teardown seam with the explicit stopped marker so cleanupRuntime gets its ownership check and no second
|
|
2718
|
-
// cleanup primitive is invented.
|
|
2719
|
-
let coldCommitted = false
|
|
2720
|
-
let coldAttempted = false
|
|
2721
|
-
try {
|
|
2722
|
-
coldAttempted = true
|
|
2723
|
-
await stopAgentProcess(id, { ...wt.rec, archived: false, stopped: lv === 'offline' }, true,
|
|
2724
|
-
preflight?.ok ? preflight.receipt : undefined)
|
|
2725
|
-
coldCommitted = true
|
|
2726
|
-
const latest = readRecord(id)
|
|
2727
|
-
if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
|
|
2728
|
-
const finalSnap = await liveSnapshot()
|
|
2729
|
-
if (finalSnap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: final liveness probe failed; the leaf may still be live`)
|
|
2730
|
-
const finalLv = h.runtimeOwnership === 'adapter'
|
|
2731
|
-
? (finalSnap.windows.has(id) ? 'online' : 'offline')
|
|
2732
|
-
: liveness({ ...latest, archived: false, stopped: false }, finalSnap)
|
|
2733
|
-
if (finalLv === 'unknown' || finalLv === 'starting' || finalLv === 'online')
|
|
2734
|
-
throw new ResourceConflict(`refusing to archive ${id}: leaf became ${finalLv} before filing`)
|
|
2735
|
-
writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null })
|
|
2736
|
-
} catch (error) {
|
|
2737
|
-
if (coldCommitted) {
|
|
2738
|
-
const restored = await h.restoreRuntime?.(wt.rec, preflight?.ok ? preflight.receipt : undefined)
|
|
2739
|
-
if (restored && !restored.ok) {
|
|
2740
|
-
const current = readRecord(id)
|
|
2741
|
-
if (current) writeRecord({ ...current, archived: false, stopped: true, coldProof: null, adapterRecovery: `restore-runtime:${restored.reason}` })
|
|
2742
|
-
console.error(`spex: archive compensation for ${id} failed: ${restored.reason}`)
|
|
2743
|
-
}
|
|
2744
|
-
} else if (coldAttempted && error instanceof Error && /compensation failed|state is unknown|reconciliation failed/i.test(error.message)) {
|
|
2745
|
-
const current = readRecord(id)
|
|
2746
|
-
if (current) writeRecord({ ...current, archived: false, stopped: true, coldProof: coldProofFor(current), adapterRecovery: `restore-runtime:${error.message}` })
|
|
2747
|
-
}
|
|
2748
|
-
throw error
|
|
2749
|
-
}
|
|
2750
|
-
requestQueueDrain()
|
|
2751
|
-
return true
|
|
2752
|
-
} finally { archiving.delete(id) }
|
|
2753
|
-
}
|
|
2754
|
-
export const archiveSession = (id: string, on = true): Promise<boolean> => {
|
|
2755
|
-
if (!on) return archiveSessionUnarchive(id)
|
|
2756
|
-
return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)))
|
|
2757
|
-
}
|
|
2758
|
-
async function archiveSessionUnarchive(id: string): Promise<boolean> {
|
|
2759
|
-
const wt = await findWorktree(id)
|
|
2760
|
-
if (!wt) return false
|
|
2761
|
-
if (!wt.rec.archived) return true
|
|
2762
|
-
const resumed = await resumeSession(id)
|
|
2763
|
-
if (!resumed.ok) throw new ResourceConflict(resumed.error || `refusing to resume ${id}`)
|
|
2764
|
-
return true
|
|
2765
|
-
}
|
|
2766
|
-
|
|
2767
|
-
// @@@ cold retirement - archive already returned the target's runtime, so closing a proven-cold row must not
|
|
2768
|
-
// re-enter the live stop guard and make unrelated shared-root references prove ownership again. Verify only
|
|
2769
|
-
// that the target-bound cold proof is still current and that no target PID/window/socket/thread has reappeared.
|
|
2770
|
-
// This is read-only: no signal, adapter mutation, or shared-root cleanup belongs on the cold path.
|
|
2771
|
-
async function assertColdRetirementSafe(id: string, rec: SessRec): Promise<void> {
|
|
2772
|
-
if (!rec.archived || !rec.stopped || !hasValidColdProof(rec))
|
|
2773
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target-bound cold witness is missing or stale`)
|
|
2774
|
-
if (rec.adapterRecovery)
|
|
2775
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`)
|
|
2776
|
-
|
|
2777
|
-
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
2778
|
-
if (snap.probeFailed) throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2779
|
-
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`)
|
|
2780
|
-
if (socket === 'live') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`)
|
|
2781
|
-
if (socket === 'unproven') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`)
|
|
2782
|
-
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2783
|
-
if (Number.isFinite(pid) && pid > 0 && processStartToken(pid))
|
|
2784
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${pid} is live or recycled; ownership is ambiguous`)
|
|
2785
|
-
|
|
2786
|
-
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2787
|
-
if (harness.coldRetirementPreflight) {
|
|
2788
|
-
const proof = await harness.coldRetirementPreflight(rec)
|
|
2789
|
-
if (!proof.ok) throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`)
|
|
2790
|
-
return
|
|
2791
|
-
}
|
|
2792
|
-
const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? []
|
|
2793
|
-
let everySharedRootAbsent = descriptors.length > 0
|
|
2794
|
-
for (const descriptor of descriptors) {
|
|
2795
|
-
const resident: { healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean } = descriptor.residency
|
|
2796
|
-
? await descriptor.residency()
|
|
2797
|
-
: await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
|
|
2798
|
-
if (!resident.healthy)
|
|
2799
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: ${resident.error || `${descriptor.label} resident census is unhealthy`}`)
|
|
2800
|
-
if (rec.harnessSessionId && resident.referenceIds.includes(rec.harnessSessionId))
|
|
2801
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target adapter thread ${rec.harnessSessionId} is loaded`)
|
|
2802
|
-
everySharedRootAbsent = everySharedRootAbsent && resident.rootAbsent === true
|
|
2803
|
-
}
|
|
2804
|
-
if (harness.coldPreflight && !everySharedRootAbsent) {
|
|
2805
|
-
const proof = await harness.coldPreflight(rec)
|
|
2806
|
-
if (!proof.ok) throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`)
|
|
2807
|
-
if (!proof.alreadyCold)
|
|
2808
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`)
|
|
2809
|
-
}
|
|
2810
|
-
}
|
|
2811
|
-
|
|
2812
|
-
// A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
|
|
2813
|
-
// this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
|
|
2814
|
-
// No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
|
|
2815
|
-
async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string, branch: string | null): Promise<void> {
|
|
2816
|
-
if (rec.status !== 'queued' || rec.harnessSessionId)
|
|
2817
|
-
throw new ResourceConflict(`refusing to close queued session ${id}: the record has a target thread or is no longer queued`)
|
|
2818
|
-
if (rec.adapterRecovery || launching.has(id))
|
|
2819
|
-
throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`)
|
|
2820
|
-
|
|
2821
|
-
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
2822
|
-
if (snap.probeFailed) throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2823
|
-
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`)
|
|
2824
|
-
if (socket === 'live') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`)
|
|
2825
|
-
if (socket === 'unproven') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous state is ambiguous`)
|
|
2826
|
-
const pidPath = sessionArtifactPath(id, 'agent.pid')
|
|
2827
|
-
if (existsSync(pidPath)) {
|
|
2828
|
-
const pid = readAgentPid(pidPath)
|
|
2829
|
-
throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`)
|
|
2830
|
-
}
|
|
2831
|
-
|
|
2832
|
-
if (existsSync(path)) {
|
|
2833
|
-
const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all'])
|
|
2834
|
-
if (!status.ok) throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree status is unreadable`)
|
|
2835
|
-
if (status.stdout.trim()) throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree has dirty work`)
|
|
2836
|
-
}
|
|
2837
|
-
if (branch) {
|
|
2838
|
-
const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`])
|
|
2839
|
-
if (resolved.ok) {
|
|
2840
|
-
const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`])
|
|
2841
|
-
if (!count.ok) throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch ancestry is unreadable`)
|
|
2842
|
-
const ahead = Number(count.stdout.trim())
|
|
2843
|
-
if (!Number.isFinite(ahead) || ahead !== 0)
|
|
2844
|
-
throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`)
|
|
2845
|
-
} else if (resolved.failure !== 'exit') {
|
|
2846
|
-
throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch identity is unreadable`)
|
|
2847
|
-
} else if (existsSync(path)) {
|
|
2848
|
-
throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree exists but branch ${branch} is missing`)
|
|
2849
|
-
}
|
|
2850
|
-
}
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
|
|
2854
|
-
const root = mainRoot()
|
|
2855
|
-
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
|
|
2856
|
-
if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
|
|
2857
|
-
const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId
|
|
2858
|
-
if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
|
|
2859
|
-
if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
|
|
2860
|
-
else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
|
|
2861
|
-
else await stopAgentProcess(id, wt.rec)
|
|
2862
|
-
let slot: string | null = null
|
|
2863
|
-
try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
2864
|
-
// a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
|
|
2865
|
-
if (existsSync(wt.path)) {
|
|
2866
|
-
const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', wt.path])
|
|
2867
|
-
if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`)
|
|
2868
|
-
if (existsSync(wt.path)) throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`)
|
|
2869
|
-
}
|
|
2870
|
-
if (wt.branch) {
|
|
2871
|
-
const branchRef = `refs/heads/${wt.branch}`
|
|
2872
|
-
const present = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef])
|
|
2873
|
-
if (present.ok) {
|
|
2874
|
-
const removed = await gitTry(['-C', root, 'branch', '-D', wt.branch])
|
|
2875
|
-
if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`)
|
|
2876
|
-
const remaining = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef])
|
|
2877
|
-
if (remaining.ok || remaining.failure !== 'exit') throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`)
|
|
2878
|
-
} else if (present.failure !== 'exit') {
|
|
2879
|
-
throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`)
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
2882
|
-
if (slot) { try { rmSync(slot, { recursive: true, force: true }) } catch { /* best-effort GC */ } }
|
|
2883
|
-
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
|
|
2884
|
-
catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
|
|
2885
|
-
if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
|
|
2886
|
-
if (closesCodexBinding && wt.rec.harnessSessionId) {
|
|
2887
|
-
bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
|
|
2888
|
-
}
|
|
2889
|
-
requestQueueDrain() // a close frees a slot — start the next queued session if any
|
|
2890
|
-
return true
|
|
2891
|
-
}
|
|
2892
|
-
async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
2893
|
-
let wt: { path: string; branch: string | null; rec: SessRec } | null = null
|
|
2894
|
-
try { wt = await findWorktree(id) }
|
|
2895
|
-
catch (e) {
|
|
2896
|
-
if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
|
|
2897
|
-
const quarantined = quarantineRecord(id)
|
|
2898
|
-
const runtime = sessionStoreDir(id)
|
|
2899
|
-
const evidence = quarantined
|
|
2900
|
-
? `Original bytes were copied to ${quarantined}`
|
|
2901
|
-
: `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`
|
|
2902
|
-
let guard = 'no readable session record proves the adapter or leaf owner'
|
|
2903
|
-
try { await stopAgentProcess(id, null) }
|
|
2904
|
-
catch (error) { guard = error instanceof Error ? error.message : String(error) }
|
|
2905
|
-
throw new SessionRecordUnusable('corrupt', id,
|
|
2906
|
-
`refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`)
|
|
2907
|
-
}
|
|
2908
|
-
if (!wt) return false
|
|
2909
|
-
const target = wt
|
|
2910
|
-
return target.branch
|
|
2911
|
-
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target))
|
|
2912
|
-
: closeOwnedSessionUnlocked(id, target)
|
|
2913
|
-
}
|
|
2914
|
-
export const closeSession = (id: string): Promise<boolean> =>
|
|
2915
|
-
withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
|
|
2916
|
-
|
|
2917
|
-
export type CorruptRecordQuarantineWitness = {
|
|
2918
|
-
adapter: string
|
|
2919
|
-
thread: string | null
|
|
2920
|
-
tmux: string
|
|
2921
|
-
worktree: string
|
|
2922
|
-
branch: string
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
export type CorruptRecordQuarantineResult = {
|
|
2926
|
-
id: string
|
|
2927
|
-
bundle: string
|
|
2928
|
-
sha256: string
|
|
2929
|
-
observedAt: string
|
|
2930
|
-
}
|
|
2931
|
-
|
|
2932
|
-
const quarantineRoot = (id: string) => join(runtimeRoot(), 'corrupt', id)
|
|
2933
|
-
const recordSha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')
|
|
2934
|
-
|
|
2935
|
-
function normalizeQuarantineWitness(id: string, raw: unknown): CorruptRecordQuarantineWitness {
|
|
2936
|
-
if (!raw || typeof raw !== 'object') throw new ResourceConflict(`refusing to quarantine ${id}: an exact adapter/thread/tmux/worktree/branch witness is required`)
|
|
2937
|
-
const value = raw as Record<string, unknown>
|
|
2938
|
-
const text = (key: keyof CorruptRecordQuarantineWitness): string => typeof value[key] === 'string' ? value[key].trim() : ''
|
|
2939
|
-
const adapter = text('adapter')
|
|
2940
|
-
const tmux = text('tmux')
|
|
2941
|
-
const worktree = text('worktree')
|
|
2942
|
-
const branch = text('branch')
|
|
2943
|
-
if (!Object.prototype.hasOwnProperty.call(value, 'thread'))
|
|
2944
|
-
throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be explicit (a string or null)`)
|
|
2945
|
-
const threadValue = value.thread
|
|
2946
|
-
const thread = typeof threadValue === 'string' && threadValue.trim() ? threadValue.trim() : threadValue == null || threadValue === '' ? null : null
|
|
2947
|
-
if (!adapter || !HARNESSES.some((h) => h.id === adapter)) throw new ResourceConflict(`refusing to quarantine ${id}: adapter must name one registered harness`)
|
|
2948
|
-
if (tmux !== id) throw new ResourceConflict(`refusing to quarantine ${id}: tmux witness must be the exact session id ${id}`)
|
|
2949
|
-
if (!worktree || !isAbsolute(worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: worktree witness must be an absolute path`)
|
|
2950
|
-
if (!branch || branch.startsWith('-') || branch.startsWith('refs/')) throw new ResourceConflict(`refusing to quarantine ${id}: branch witness must be one local branch name`)
|
|
2951
|
-
if (threadValue !== undefined && threadValue !== null && typeof threadValue !== 'string') throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be a string or null`)
|
|
2952
|
-
return { adapter, thread, tmux, worktree: resolve(worktree), branch }
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
|
-
async function proveQuarantineTmuxAbsent(id: string): Promise<{ state: 'absent' }> {
|
|
2956
|
-
try { await tmux(['has-session', '-t', id], TMUX_PROBE_TIMEOUT_MS) }
|
|
2957
|
-
catch (error) {
|
|
2958
|
-
if (probeTimedOut(error)) throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (probe timed out)`)
|
|
2959
|
-
if (typeof (error as NodeJS.ErrnoException).code === 'number') return { state: 'absent' }
|
|
2960
|
-
throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (${error instanceof Error ? error.message : String(error)})`)
|
|
2961
|
-
}
|
|
2962
|
-
throw new ResourceConflict(`refusing to quarantine ${id}: exact tmux session ${id} is live`)
|
|
2963
|
-
}
|
|
2964
|
-
|
|
2965
|
-
async function proveQuarantineGitAbsent(id: string, witness: CorruptRecordQuarantineWitness): Promise<{
|
|
2966
|
-
worktree: { state: 'absent' }
|
|
2967
|
-
branch: { state: 'absent' }
|
|
2968
|
-
}> {
|
|
2969
|
-
if (existsSync(witness.worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} is live`)
|
|
2970
|
-
const root = mainRoot()
|
|
2971
|
-
const listed = await gitTry(['-C', root, 'worktree', 'list', '--porcelain'])
|
|
2972
|
-
if (!listed.ok) throw new ResourceConflict(`refusing to quarantine ${id}: worktree registry is unknown`)
|
|
2973
|
-
const registered = listed.stdout.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => resolve(line.slice('worktree '.length)))
|
|
2974
|
-
if (registered.includes(witness.worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} remains registered`)
|
|
2975
|
-
const branch = await gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${witness.branch}`])
|
|
2976
|
-
if (branch.ok) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed branch ${witness.branch} is live`)
|
|
2977
|
-
if (branch.failure !== 'exit') throw new ResourceConflict(`refusing to quarantine ${id}: branch absence is unknown`)
|
|
2978
|
-
return { worktree: { state: 'absent' }, branch: { state: 'absent' } }
|
|
2979
|
-
}
|
|
2980
|
-
|
|
2981
|
-
function proveQuarantineLeafAbsent(id: string): { state: 'absent'; artifact: 'missing' | `stale:${number}` } {
|
|
2982
|
-
const path = sessionArtifactPath(id, 'agent.pid')
|
|
2983
|
-
let text: string
|
|
2984
|
-
try { text = readFileSync(path, 'utf8').trim() }
|
|
2985
|
-
catch (error) {
|
|
2986
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { state: 'absent', artifact: 'missing' }
|
|
2987
|
-
throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is unreadable`)
|
|
2988
|
-
}
|
|
2989
|
-
const pid = Number(text)
|
|
2990
|
-
if (!Number.isSafeInteger(pid) || pid <= 0) throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is malformed`)
|
|
2991
|
-
const start = processStartToken(pid)
|
|
2992
|
-
if (start) throw new ResourceConflict(`refusing to quarantine ${id}: registered agent process ${pid}@${start} is live or recycled`)
|
|
2993
|
-
return { state: 'absent', artifact: `stale:${pid}` }
|
|
2994
|
-
}
|
|
2995
|
-
|
|
2996
|
-
async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuarantineWitness): Promise<{
|
|
2997
|
-
adapter: string
|
|
2998
|
-
thread: string | null
|
|
2999
|
-
action: 'absent' | 'archived' | 'already-unloaded'
|
|
3000
|
-
compensate: () => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
3001
|
-
}> {
|
|
3002
|
-
const harness = harnessById(witness.adapter)
|
|
3003
|
-
if (harness.ownsRendezvous) {
|
|
3004
|
-
const socket = await rendezvousListening(id)
|
|
3005
|
-
if (socket === 'live') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport is live`)
|
|
3006
|
-
if (socket === 'unproven') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport absence is unknown`)
|
|
3007
|
-
}
|
|
3008
|
-
if (witness.thread) {
|
|
3009
|
-
if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} has no archivable native thread; omit --thread (a SpexCode session id is not an adapter thread)`)
|
|
3010
|
-
const native = await harness.quarantineOrphanThread(witness.thread, { excludingSessionId: id })
|
|
3011
|
-
if (!native.ok) throw new ResourceConflict(`refusing to quarantine ${id}: ${native.reason}`)
|
|
3012
|
-
return { adapter: native.audit.adapter, thread: native.audit.threadId, action: native.audit.action, compensate: native.compensate }
|
|
3013
|
-
}
|
|
3014
|
-
const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? []
|
|
3015
|
-
for (const descriptor of descriptors) {
|
|
3016
|
-
if (!descriptor.residency) throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} has no exact absence census`)
|
|
3017
|
-
let residency: Awaited<ReturnType<NonNullable<typeof descriptor.residency>>>
|
|
3018
|
-
try { residency = await descriptor.residency() }
|
|
3019
|
-
catch (error) { throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${error instanceof Error ? error.message : String(error)})`) }
|
|
3020
|
-
if (!residency.healthy) throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${residency.error || 'unhealthy census'})`)
|
|
3021
|
-
if (!residency.rootAbsent || residency.referenceIds.length)
|
|
3022
|
-
throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} is live; supply its exact native thread instead of claiming absence`)
|
|
3023
|
-
}
|
|
3024
|
-
return { adapter: harness.id, thread: null, action: 'absent', compensate: async () => ({ ok: true }) }
|
|
3025
|
-
}
|
|
3026
|
-
|
|
3027
|
-
export async function quarantineCorruptRecord(id: string, rawWitness: unknown): Promise<CorruptRecordQuarantineResult> {
|
|
3028
|
-
return withSessionTransition(id, () => withRecordLock(id, async () => {
|
|
3029
|
-
const entry = readRecordEntry(id)
|
|
3030
|
-
if (entry.kind === 'absent') throw new ResourceConflict(`refusing to quarantine ${id}: no active session record exists`)
|
|
3031
|
-
if (entry.kind === 'ok') throw new ResourceConflict(`refusing to quarantine ${id}: record is readable; use its ordinary lifecycle control`)
|
|
3032
|
-
const witness = normalizeQuarantineWitness(id, rawWitness)
|
|
3033
|
-
const original = readFileSync(entry.path)
|
|
3034
|
-
const sha256 = recordSha256(original)
|
|
3035
|
-
const observedAt = new Date().toISOString()
|
|
3036
|
-
const leaf = proveQuarantineLeafAbsent(id)
|
|
3037
|
-
const tmux = await proveQuarantineTmuxAbsent(id)
|
|
3038
|
-
const git = await proveQuarantineGitAbsent(id, witness)
|
|
3039
|
-
const adapter = await proveQuarantineAdapter(id, witness)
|
|
3040
|
-
const bundle = join(quarantineRoot(id), `${observedAt.replace(/[:.]/g, '-')}-${randomUUID()}`)
|
|
3041
|
-
const stored = join(bundle, 'session.json')
|
|
3042
|
-
const provenance = join(bundle, 'provenance.json')
|
|
3043
|
-
const audit = {
|
|
3044
|
-
version: 1,
|
|
3045
|
-
sessionId: id,
|
|
3046
|
-
observedAt,
|
|
3047
|
-
record: { activePath: entry.path, sha256, bytes: original.length },
|
|
3048
|
-
witness,
|
|
3049
|
-
observed: { leaf, tmux, ...git, adapter: { adapter: adapter.adapter, thread: adapter.thread, action: adapter.action } },
|
|
3050
|
-
}
|
|
3051
|
-
try {
|
|
3052
|
-
mkdirSync(bundle, { recursive: true, mode: 0o700 })
|
|
3053
|
-
const temp = `${provenance}.${process.pid}.${randomUUID()}.tmp`
|
|
3054
|
-
writeFileSync(temp, `${JSON.stringify(audit, null, 2)}\n`, { mode: 0o600 })
|
|
3055
|
-
renameSync(temp, provenance)
|
|
3056
|
-
const current = readRecordEntry(id)
|
|
3057
|
-
if (current.kind !== 'corrupt' || current.path !== entry.path) throw new ResourceConflict(`refusing to quarantine ${id}: active record changed during absence verification`)
|
|
3058
|
-
const currentBytes = readFileSync(current.path)
|
|
3059
|
-
if (recordSha256(currentBytes) !== sha256) throw new ResourceConflict(`refusing to quarantine ${id}: opaque record changed during absence verification`)
|
|
3060
|
-
renameSync(current.path, stored)
|
|
3061
|
-
if (recordSha256(readFileSync(stored)) !== sha256) {
|
|
3062
|
-
renameSync(stored, current.path)
|
|
3063
|
-
throw new ResourceConflict(`refusing to quarantine ${id}: moved record failed byte-exact verification`)
|
|
3064
|
-
}
|
|
3065
|
-
} catch (error) {
|
|
3066
|
-
const restored = await adapter.compensate()
|
|
3067
|
-
const suffix = restored.ok ? '' : `; native orphan compensation failed: ${restored.reason}`
|
|
3068
|
-
if (error instanceof ResourceConflict) throw new ResourceConflict(`${error.message}${suffix}`)
|
|
3069
|
-
throw new ResourceConflict(`refusing to quarantine ${id}: ${error instanceof Error ? error.message : String(error)}${suffix}`)
|
|
3070
|
-
}
|
|
3071
|
-
return { id, bundle, sha256, observedAt }
|
|
3072
|
-
}))
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecordQuarantineResult> {
|
|
3076
|
-
return withSessionTransition(id, () => withRecordLock(id, async () => {
|
|
3077
|
-
if (readRecordEntry(id).kind !== 'absent') throw new ResourceConflict(`refusing to restore ${id}: an active session record already exists`)
|
|
3078
|
-
let bundles: string[]
|
|
3079
|
-
try { bundles = readdirSync(quarantineRoot(id), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse() }
|
|
3080
|
-
catch (error) {
|
|
3081
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ResourceConflict(`refusing to restore ${id}: no quarantine bundle exists`)
|
|
3082
|
-
throw new ResourceConflict(`refusing to restore ${id}: quarantine bundle inventory is unreadable`)
|
|
3083
|
-
}
|
|
3084
|
-
const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'session.json')) && existsSync(join(path, 'provenance.json')))
|
|
3085
|
-
if (!bundle) throw new ResourceConflict(`refusing to restore ${id}: no complete quarantine bundle exists`)
|
|
3086
|
-
const stored = join(bundle, 'session.json')
|
|
3087
|
-
let provenance: { sessionId?: unknown; record?: { sha256?: unknown } }
|
|
3088
|
-
try { provenance = JSON.parse(readFileSync(join(bundle, 'provenance.json'), 'utf8')) }
|
|
3089
|
-
catch { throw new ResourceConflict(`refusing to restore ${id}: quarantine provenance is unreadable`) }
|
|
3090
|
-
const bytes = readFileSync(stored)
|
|
3091
|
-
if (provenance.sessionId !== id || typeof provenance.record?.sha256 !== 'string' || provenance.record.sha256 !== recordSha256(bytes))
|
|
3092
|
-
throw new ResourceConflict(`refusing to restore ${id}: quarantine payload/provenance binding is invalid`)
|
|
3093
|
-
const active = sessionRecordPath(id)
|
|
3094
|
-
mkdirSync(dirname(active), { recursive: true })
|
|
3095
|
-
try { renameSync(stored, active) }
|
|
3096
|
-
catch (error) { throw new ResourceConflict(`refusing to restore ${id}: opaque record move failed (${error instanceof Error ? error.message : String(error)})`) }
|
|
3097
|
-
if (recordSha256(readFileSync(active)) !== provenance.record.sha256) {
|
|
3098
|
-
renameSync(active, stored)
|
|
3099
|
-
throw new ResourceConflict(`refusing to restore ${id}: restored record failed byte-exact verification`)
|
|
3100
|
-
}
|
|
3101
|
-
return { id, bundle, sha256: provenance.record.sha256, observedAt: new Date().toISOString() }
|
|
3102
|
-
}))
|
|
3103
|
-
}
|
|
3104
|
-
|
|
3105
|
-
function quarantineRecord(id: string): string | null {
|
|
3106
|
-
let entry
|
|
3107
|
-
try { entry = readRecordEntry(id) } catch { return null } // unreadable for another reason (permissions) — leave it
|
|
3108
|
-
if (entry.kind !== 'corrupt') return null
|
|
3109
|
-
try {
|
|
3110
|
-
const shelf = join(runtimeRoot(), 'corrupt')
|
|
3111
|
-
mkdirSync(shelf, { recursive: true })
|
|
3112
|
-
const dest = join(shelf, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`)
|
|
3113
|
-
writeFileSync(dest, readFileSync(entry.path))
|
|
3114
|
-
console.error(`spex: session ${id.slice(0, 8)} had an unreadable record; its original bytes are preserved at ${dest}`)
|
|
3115
|
-
return dest
|
|
3116
|
-
} catch (e) {
|
|
3117
|
-
console.error(`spex: could not quarantine the unreadable record for ${id}: ${e instanceof Error ? e.message : e}`)
|
|
3118
|
-
return null
|
|
3119
|
-
}
|
|
3120
|
-
}
|
|
3121
|
-
|
|
3122
|
-
// @@@ captureSessionResult - the session's live pane as a one-shot snapshot (output), the server side of
|
|
3123
|
-
// `GET /api/sessions/:id/capture` that `spex session show --capture` (a backend client) reads. A monitoring read MUST
|
|
3124
|
-
// distinguish "I failed to read" from "the pane is genuinely empty" — the old captureSession collapsed
|
|
3125
|
-
// unknown-id, offline, and capture-error all to `''`, indistinguishable from an empty pane (a blank screen
|
|
3126
|
-
// that exits 0 is worse than useless to a manager). So the result is DISCRIMINATED: an empty pane is a
|
|
3127
|
-
// legitimate `{ok:true, pane:''}`; the three failure modes carry distinct reasons the route maps to distinct
|
|
3128
|
-
// HTTP codes (unknown→404, offline→409, capture-failed→502). The known-vs-offline check only runs on the
|
|
3129
|
-
// cold/not-alive branch, so a live capture (the polled hot path) costs just the one capture-pane.
|
|
3130
|
-
export type CaptureResult = { ok: true; pane: string } | { ok: false; reason: 'unknown' | 'offline' | 'capture-failed' }
|
|
3131
|
-
export async function captureSessionResult(id: string): Promise<CaptureResult> {
|
|
3132
|
-
if (!(await alive(id))) {
|
|
3133
|
-
const known = (await listSessions(true)).some((s) => s.id === id)
|
|
3134
|
-
return { ok: false, reason: known ? 'offline' : 'unknown' }
|
|
3135
|
-
}
|
|
3136
|
-
try { return { ok: true, pane: await tmux(['capture-pane', '-e', '-p', '-t', id]) } }
|
|
3137
|
-
catch { return { ok: false, reason: 'capture-failed' } }
|
|
3138
|
-
}
|
|
3139
|
-
|
|
3140
|
-
// @@@ watch - the event source for Claude Code's Monitor tool (first-class managing-agent support).
|
|
3141
|
-
// Polls the session list and emits the COMPLETE session lifecycle so it's a true "subscribe to all
|
|
3142
|
-
// session changes" feed: a LAUNCH (first sighting of an id, even though it enters at 'working', which is
|
|
3143
|
-
// not actionable — emitted ONCE per id so a manager learns a new session started), each ACTIONABLE state
|
|
3144
|
-
// transition — review / done / close-pending (agent proposals), offline (process died), error — and the
|
|
3145
|
-
// removal. Per Monitor's "silence is not success" rule a vanished session pings too. Net feed:
|
|
3146
|
-
// launched → [actionable transitions] → closed. Each line names the suggested next action(s). Drop into Monitor:
|
|
3147
|
-
// Monitor({ command: 'spex session watch', persistent: true, description: 'session state changes' })
|
|
3148
|
-
// @@@ presentation + selection - shared by `spex session ls` (pretty), `spex session watch` (events) and the API.
|
|
3149
|
-
export const STATUS_GLYPH: Record<DisplayStatus, string> = {
|
|
3150
|
-
working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
|
|
3151
|
-
'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
|
|
3152
|
-
corrupt: '\u26a0', retired: '\u2691',
|
|
3153
|
-
}
|
|
3154
|
-
const ANSI: Record<DisplayStatus, string> = {
|
|
3155
|
-
working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90', unknown: '93',
|
|
3156
|
-
corrupt: '31', retired: '90',
|
|
3157
|
-
}
|
|
3158
|
-
|
|
3159
|
-
// @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
|
|
3160
|
-
// selector matches a session iff it is the session's full id, an id-PREFIX, its node, its branch, or `.` for
|
|
3161
|
-
// the caller's own launched session. This is
|
|
3162
|
-
// the single predicate; selectSessions (MANY) and resolveSession (ONE) both call it, so id-prefix/node/branch
|
|
3163
|
-
// resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
|
|
3164
|
-
// review/merge/send/close act on".
|
|
3165
|
-
export function matchesSelector(s: Session, q: string, own = ownSessionId(), cwd = process.cwd()): boolean {
|
|
3166
|
-
// a selector may be a comma-separated list (the same convention as `--status a,b`): it matches iff ANY part
|
|
3167
|
-
// names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
|
|
3168
|
-
// is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
|
|
3169
|
-
// comma, so without the split `a,b` would be one literal selector that matches no session and streams in
|
|
3170
|
-
// silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
|
|
3171
|
-
// the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
|
|
3172
|
-
const sessionPath = s.path ? resolve(s.path) : null
|
|
3173
|
-
const callerPath = resolve(cwd)
|
|
3174
|
-
const self = Boolean(own) && s.id === own
|
|
3175
|
-
|| Boolean(sessionPath) && (callerPath === sessionPath || callerPath.startsWith(`${sessionPath}${sep}`))
|
|
3176
|
-
return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
|
|
3177
|
-
.some((p) => p === '.' ? self : s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
|
|
3178
|
-
}
|
|
3179
|
-
|
|
3180
|
-
// no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
|
|
3181
|
-
export function selectSessions(all: Session[], selectors: string[], statuses?: string[], own = ownSessionId(), cwd = process.cwd()): Session[] {
|
|
3182
|
-
let out = all
|
|
3183
|
-
const sel = selectors.filter((x) => x && x !== '@all')
|
|
3184
|
-
if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q, own, cwd)))
|
|
3185
|
-
if (statuses && statuses.length) out = out.filter((s) => statuses.includes(s.status))
|
|
3186
|
-
return out
|
|
3187
|
-
}
|
|
3188
|
-
|
|
3189
|
-
// @@@ resolveSession - resolve ONE selector to ONE session against a board: the single-target counterpart of
|
|
3190
|
-
// selectSessions, for the control verbs (review/send/merge/close/resume/show). The backend matches
|
|
3191
|
-
// ids EXACTLY, so a verb resolves the selector here first and then calls with the FULL id — a node/branch/
|
|
3192
|
-
// prefix selector drives a verb just as it filters `ls`. The result is DISCRIMINATED so a caller can fail
|
|
3193
|
-
// precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
|
|
3194
|
-
// otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
|
|
3195
|
-
export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
|
|
3196
|
-
export function resolveSession(selector: string, sessions: Session[], own = ownSessionId(), cwd = process.cwd()): Resolved {
|
|
3197
|
-
// the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
|
|
3198
|
-
const exact = sessions.find((s) => s.id === stripRefSigil(selector))
|
|
3199
|
-
if (exact) return { ok: exact }
|
|
3200
|
-
const hits = sessions.filter((s) => matchesSelector(s, selector, own, cwd))
|
|
3201
|
-
if (hits.length === 1) return { ok: hits[0] }
|
|
3202
|
-
return hits.length ? { ambiguous: hits } : { none: true }
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
|
-
// @@@ display width - the table aligns by TERMINAL CELLS, not code units. CJK/fullwidth glyphs render
|
|
3206
|
-
// two cells wide, so `slice`/`padEnd` (which count code units) shear a wide glyph mid-cut and under-pad
|
|
3207
|
-
// the column, misaligning everything after it. A small wcwidth-style range check covers the wide blocks
|
|
3208
|
-
// that actually reach session labels/prompts \u2014 no dependency needed.
|
|
3209
|
-
const isWideCp = (cp: number): boolean =>
|
|
3210
|
-
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
3211
|
-
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK radicals \u2026 kana \u2026 CJK ideographs \u2026 Yi
|
|
3212
|
-
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
3213
|
-
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
|
|
3214
|
-
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
|
|
3215
|
-
(cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
|
|
3216
|
-
(cp >= 0xffe0 && cp <= 0xffe6) || // fullwidth signs
|
|
3217
|
-
(cp >= 0x1f300 && cp <= 0x1faff) || // emoji
|
|
3218
|
-
(cp >= 0x20000 && cp <= 0x3fffd) // CJK extensions B+
|
|
3219
|
-
export function displayWidth(s: string): number {
|
|
3220
|
-
let w = 0
|
|
3221
|
-
for (const ch of s) w += isWideCp(ch.codePointAt(0)!) ? 2 : 1
|
|
3222
|
-
return w
|
|
3223
|
-
}
|
|
3224
|
-
// truncate to a display width (the ellipsis occupies its own cell); never cuts a wide glyph in half.
|
|
3225
|
-
export function truncWidth(s: string, max: number): string {
|
|
3226
|
-
if (displayWidth(s) <= max) return s
|
|
3227
|
-
let w = 0
|
|
3228
|
-
let out = ''
|
|
3229
|
-
for (const ch of s) {
|
|
3230
|
-
const cw = isWideCp(ch.codePointAt(0)!) ? 2 : 1
|
|
3231
|
-
if (w + cw > max - 1) break
|
|
3232
|
-
out += ch
|
|
3233
|
-
w += cw
|
|
3234
|
-
}
|
|
3235
|
-
return out + '\u2026'
|
|
3236
|
-
}
|
|
3237
|
-
// pad to a display width \u2014 `padEnd` would count a double-cell glyph as one and under-pad the column.
|
|
3238
|
-
export const padWidth = (s: string, w: number): string => s + ' '.repeat(Math.max(0, w - displayWidth(s)))
|
|
3239
|
-
const trunc = truncWidth
|
|
3240
|
-
// the board table's NOTE display cap \u2014 exported so the declaration echo (cli.ts) can tell an author
|
|
3241
|
-
// exactly where their note gets cut, instead of the cap living as an anonymous magic number here.
|
|
3242
|
-
export const NOTE_BOARD_LIMIT = 50
|
|
3243
|
-
// short display label per status (only close-pending differs from the status name) \u2014 used by the legend.
|
|
3244
|
-
const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
|
|
3245
|
-
|
|
3246
|
-
// @@@ statusLegend - one-line glyph\u2192meaning key, BUILT from STATUS_GLYPH so it can never drift from
|
|
3247
|
-
// the glyphs the table actually prints. Shown under `spex session ls` so the symbols are self-explanatory.
|
|
3248
|
-
export function statusLegend(color = true): string {
|
|
3249
|
-
const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
3250
|
-
const parts = (Object.keys(STATUS_GLYPH) as DisplayStatus[]).map(
|
|
3251
|
-
(k) => `${c(ANSI[k], STATUS_GLYPH[k])} ${SHORT[k] || k}`,
|
|
3252
|
-
)
|
|
3253
|
-
return c('90', ' key: ') + parts.join(' ')
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
|
-
// human-friendly aligned table: header + (glyph + colour + status + name + id + merges + note) rows +
|
|
3257
|
-
// a status legend, so the table tells the whole story (incl. each agent's note) at a glance.
|
|
3258
|
-
export function formatTable(sessions: Session[], color = true): string {
|
|
3259
|
-
const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
3260
|
-
if (!sessions.length) return c('90', ' no living sessions')
|
|
3261
|
-
const header = c('90', ` ${'STATUS'.padEnd(13)} ${'NODE'.padEnd(22)} ${'ID'.padEnd(8)} ${'\u00d7'.padEnd(4)}${'PROMPT'.padEnd(42)}NOTE`)
|
|
3262
|
-
const rows = sessions.map((s) => {
|
|
3263
|
-
const g = STATUS_GLYPH[s.status] ?? '\u00b7'
|
|
3264
|
-
const code = ANSI[s.status] ?? '0'
|
|
3265
|
-
const name = padWidth(truncWidth(sessionLabel(s), 22), 22)
|
|
3266
|
-
const st = s.status.padEnd(13)
|
|
3267
|
-
const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
|
|
3268
|
-
const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)) // what it was asked to do
|
|
3269
|
-
const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
|
|
3270
|
-
return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
|
|
3271
|
-
})
|
|
3272
|
-
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
3273
|
-
}
|
|
3274
|
-
|
|
3275
|
-
// @@@ sendText - THE APPEND ACCEPTS, THE QUEUE OWES ([[dispatch]]). One hold of the record lock records the
|
|
3276
|
-
// message in the durable log AND enqueues it ([[delivery-queue]]); success is decided by that write, so a
|
|
3277
|
-
// sender learns whether the message was accepted and never whether a socket was reachable. The handover is a
|
|
3278
|
-
// separate act: drain the queue into the harness adapter as an ordinary prompt. What stays LOUD is only what
|
|
3279
|
-
// genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
|
|
3280
|
-
// A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
|
|
3281
|
-
// that cannot reach an agent must at least leave a trace ([[session-timeline]]).
|
|
3282
|
-
// (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
3283
|
-
export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
|
|
3284
|
-
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
3285
|
-
const rec = readRecord(id)
|
|
3286
|
-
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
3287
|
-
// Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
|
|
3288
|
-
// the queue keeps the transport form. Composing again at handover would let a later send change the hints on
|
|
3289
|
-
// a message that was already accepted.
|
|
3290
|
-
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3291
|
-
try {
|
|
3292
|
-
await withRecordLock(id, async () => {
|
|
3293
|
-
const appended = appendSent(id, text, from ?? null, prompt.replyVia)
|
|
3294
|
-
enqueue(id, { mid: appended.mid, text: prompt.text, from: from ?? null })
|
|
3295
|
-
})
|
|
3296
|
-
} catch (error) {
|
|
3297
|
-
return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
|
|
3298
|
-
}
|
|
3299
|
-
// Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
|
|
3300
|
-
// costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
|
|
3301
|
-
// the text in a live agent's current turn instead of up to one tick later.
|
|
3302
|
-
await drainSession(id)
|
|
3303
|
-
return { ok: true }
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
// @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
|
|
3307
|
-
// at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
|
|
3308
|
-
// The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
|
|
3309
|
-
export async function drainSession(id: string): Promise<void> {
|
|
3310
|
-
if (!owesDelivery(id)) return
|
|
3311
|
-
const rec = readRecord(id)
|
|
3312
|
-
if (!rec) return
|
|
3313
|
-
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3314
|
-
await drain(id, async (msg) => {
|
|
3315
|
-
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
3316
|
-
// prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
|
|
3317
|
-
// rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
|
|
3318
|
-
if (h.deliveryBlockedBy) {
|
|
3319
|
-
try {
|
|
3320
|
-
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
|
|
3321
|
-
} catch { /* no pane to consult — let the insert itself decide */ }
|
|
3322
|
-
}
|
|
3323
|
-
return (await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)).ok
|
|
3324
|
-
})
|
|
3325
|
-
}
|
|
3326
|
-
|
|
3327
|
-
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
|
|
3328
|
-
// confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
|
|
3329
|
-
export async function interruptSession(id: string): Promise<DispatchResult> {
|
|
3330
|
-
return withRecordLock(id, async () => {
|
|
3331
|
-
const rec = readRecord(id)
|
|
3332
|
-
if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
|
|
3333
|
-
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3334
|
-
if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
|
|
3335
|
-
return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
|
|
3336
|
-
})
|
|
3337
|
-
}
|
|
3338
|
-
|
|
3339
|
-
// @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
|
|
3340
|
-
// socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
|
|
3341
|
-
// agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
|
|
3342
|
-
// move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
|
|
3343
|
-
// keystroke-navigation state its input box is replaced by the menu, so the CLI raw-key fallback forwards
|
|
3344
|
-
// each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
|
|
3345
|
-
// key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
|
|
3346
|
-
// also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
|
|
3347
|
-
// prefix on the token (e.g. `C-r`, `M-b`, `S-Tab`, `C-M-x`); those are passed to tmux UNescaped so it parses
|
|
3348
|
-
// the combo. One key per call, no socket and no Enter-synthesis — this IS the send-keys channel. False if
|
|
3349
|
-
// the tmux session is gone, or if the token isn't a known base after its prefixes (defends the send-keys arg).
|
|
3350
|
-
const TMUX_KEY: Record<string, string> = {
|
|
3351
|
-
Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right',
|
|
3352
|
-
Enter: 'Enter', Escape: 'Escape', Tab: 'Tab', Space: 'Space', Backspace: 'BSpace',
|
|
3353
|
-
Home: 'Home', End: 'End', Delete: 'DC',
|
|
3354
|
-
}
|
|
3355
|
-
// tmux honours an `S-` (shift) modifier ONLY on these named keys; on Enter/Space/BSpace it would send the
|
|
3356
|
-
// literal text "S-Enter" etc. (and shift is a no-op there anyway), so a stray S- is dropped. Shift+Tab is
|
|
3357
|
-
// the named exception: tmux spells it `BTab` (back-tab → ESC[Z, what Claude Code's mode-cycle reads).
|
|
3358
|
-
const SHIFTABLE = new Set(['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'DC'])
|
|
3359
|
-
// resolve ONE frontend token to the `tmux send-keys` args for it, or null if it isn't a known base after its
|
|
3360
|
-
// prefixes (defends the send-keys arg). Pure — the batch loop below sequences the actual sends.
|
|
3361
|
-
function rawKeyArgs(id: string, key: string): string[] | null {
|
|
3362
|
-
// peel the optional C-/M-/S- modifier prefixes (each at most once, in any order) off the front; the
|
|
3363
|
-
// remainder is the BASE key. The frontend only ever sends {C-,M-,S-} prefixes + a named key or one char.
|
|
3364
|
-
let rest = key, prefix = ''
|
|
3365
|
-
const seen = new Set<string>()
|
|
3366
|
-
while (rest.length >= 2 && (rest[0] === 'C' || rest[0] === 'M' || rest[0] === 'S') && rest[1] === '-' && !seen.has(rest[0])) {
|
|
3367
|
-
seen.add(rest[0]); prefix += rest.slice(0, 2); rest = rest.slice(2)
|
|
3368
|
-
}
|
|
3369
|
-
const named = TMUX_KEY[rest]
|
|
3370
|
-
if (named) {
|
|
3371
|
-
const noShift = prefix.replace('S-', '') // C-/M- without the shift bit
|
|
3372
|
-
let token: string
|
|
3373
|
-
if (prefix.includes('S-') && named === 'Tab') token = noShift + 'BTab' // Shift+Tab → back-tab
|
|
3374
|
-
else if (prefix.includes('S-') && !SHIFTABLE.has(named)) token = noShift + named // tmux can't carry S- here
|
|
3375
|
-
else token = prefix + named
|
|
3376
|
-
return ['send-keys', '-t', id, token]
|
|
3377
|
-
}
|
|
3378
|
-
if ([...rest].length === 1) {
|
|
3379
|
-
// a single printable char: bare → literal (`-l`, so tmux never reinterprets it as a key name);
|
|
3380
|
-
// modified → hand tmux the `C-`/`M-`/`S-` combo to parse (e.g. `C-a`), which `-l` would defeat.
|
|
3381
|
-
if (prefix) return ['send-keys', '-t', id, prefix + rest]
|
|
3382
|
-
return ['send-keys', '-t', id, '-l', '--', rest]
|
|
3383
|
-
}
|
|
3384
|
-
return null
|
|
3385
|
-
}
|
|
3386
|
-
// One call carries a BATCH of tokens (or one) — the client coalesces fast typing into an ordered array. Order
|
|
3387
|
-
// is the whole point ([[nav-mode-key-ordering]]): the keys are sent by ONE awaited `send-keys` each, IN ARRAY
|
|
3388
|
-
// ORDER, so they reach the pane in exactly the order they were struck. Concurrent per-key POSTs used to race
|
|
3389
|
-
// (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
|
|
3390
|
-
// An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
|
|
3391
|
-
export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
|
|
3392
|
-
return withRecordLock(id, async () => {
|
|
3393
|
-
const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
|
|
3394
|
-
if (list.length === 0 || !(await alive(id))) return false
|
|
3395
|
-
let sent = false
|
|
3396
|
-
for (const k of list) {
|
|
3397
|
-
const args = rawKeyArgs(id, k)
|
|
3398
|
-
if (!args) continue
|
|
3399
|
-
await tmux(args); sent = true
|
|
3400
|
-
}
|
|
3401
|
-
return sent
|
|
3402
|
-
})
|
|
3403
|
-
}
|