oh-my-codex 0.21.0 → 0.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.toml +1 -1
- package/README.md +4 -2
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.d.ts +2 -0
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.d.ts.map +1 -0
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.js +592 -0
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.js.map +1 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.js +270 -33
- package/dist/cli/__tests__/detached-leader-teardown.test.js.map +1 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js +133 -5
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +543 -2
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.d.ts +2 -0
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.d.ts.map +1 -0
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.js +17 -0
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.js.map +1 -0
- package/dist/cli/__tests__/launch-fallback.test.js +15 -3
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/package-bin-contract.test.js +20 -0
- package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.d.ts +2 -0
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.d.ts.map +1 -0
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.js +76 -0
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.js.map +1 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.d.ts +2 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.d.ts.map +1 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.js +1662 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.js.map +1 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.d.ts +2 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.d.ts.map +1 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.js +522 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.js.map +1 -0
- package/dist/cli/__tests__/ralplan-advisory.test.d.ts +2 -0
- package/dist/cli/__tests__/ralplan-advisory.test.d.ts.map +1 -0
- package/dist/cli/__tests__/ralplan-advisory.test.js +301 -0
- package/dist/cli/__tests__/ralplan-advisory.test.js.map +1 -0
- package/dist/cli/__tests__/ralplan.test.js +39 -0
- package/dist/cli/__tests__/ralplan.test.js.map +1 -1
- package/dist/cli/__tests__/resume.test.js +81 -6
- package/dist/cli/__tests__/resume.test.js.map +1 -1
- package/dist/cli/__tests__/session-scoped-runtime.test.js +423 -4
- package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
- package/dist/cli/__tests__/setup-install-mode.test.js +60 -3
- package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
- package/dist/cli/__tests__/setup-refresh.test.js +9 -0
- package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
- package/dist/cli/__tests__/sparkshell-cli.test.js +4 -0
- package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
- package/dist/cli/__tests__/ultragoal.test.js +71 -0
- package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js +30 -4
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +57 -8
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +113 -7
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +1850 -185
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/native-assets.d.ts +2 -1
- package/dist/cli/native-assets.d.ts.map +1 -1
- package/dist/cli/native-assets.js +7 -7
- package/dist/cli/native-assets.js.map +1 -1
- package/dist/cli/plugin-marketplace.d.ts +89 -4
- package/dist/cli/plugin-marketplace.d.ts.map +1 -1
- package/dist/cli/plugin-marketplace.js +2135 -118
- package/dist/cli/plugin-marketplace.js.map +1 -1
- package/dist/cli/ralplan.d.ts +2 -1
- package/dist/cli/ralplan.d.ts.map +1 -1
- package/dist/cli/ralplan.js +149 -9
- package/dist/cli/ralplan.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +7 -1
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/sparkshell.d.ts.map +1 -1
- package/dist/cli/sparkshell.js +9 -10
- package/dist/cli/sparkshell.js.map +1 -1
- package/dist/cli/update-worker.js +9 -0
- package/dist/cli/update-worker.js.map +1 -1
- package/dist/cli/update.d.ts +1 -0
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +9 -0
- package/dist/cli/update.js.map +1 -1
- package/dist/config/__tests__/generator-idempotent.test.js +118 -0
- package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +74 -4
- package/dist/config/generator.js.map +1 -1
- package/dist/hooks/__tests__/keyword-detector.test.js +74 -0
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.d.ts +2 -0
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.js +250 -0
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.js.map +1 -0
- package/dist/hooks/__tests__/session.test.js +10 -1
- package/dist/hooks/__tests__/session.test.js.map +1 -1
- package/dist/hooks/keyword-detector.d.ts +8 -1
- package/dist/hooks/keyword-detector.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.js +130 -9
- package/dist/hooks/keyword-detector.js.map +1 -1
- package/dist/hooks/session.d.ts +5 -0
- package/dist/hooks/session.d.ts.map +1 -1
- package/dist/hooks/session.js +17 -3
- package/dist/hooks/session.js.map +1 -1
- package/dist/hud/__tests__/index.test.js +16 -1
- package/dist/hud/__tests__/index.test.js.map +1 -1
- package/dist/hud/__tests__/reconcile.test.js +134 -7
- package/dist/hud/__tests__/reconcile.test.js.map +1 -1
- package/dist/hud/__tests__/render.test.js +57 -0
- package/dist/hud/__tests__/render.test.js.map +1 -1
- package/dist/hud/__tests__/session-attached.test.d.ts +2 -0
- package/dist/hud/__tests__/session-attached.test.d.ts.map +1 -0
- package/dist/hud/__tests__/session-attached.test.js +82 -0
- package/dist/hud/__tests__/session-attached.test.js.map +1 -0
- package/dist/hud/__tests__/state.test.js +94 -2
- package/dist/hud/__tests__/state.test.js.map +1 -1
- package/dist/hud/__tests__/tmux-split-realtmux.test.js +169 -2
- package/dist/hud/__tests__/tmux-split-realtmux.test.js.map +1 -1
- package/dist/hud/__tests__/tmux.test.js +104 -22
- package/dist/hud/__tests__/tmux.test.js.map +1 -1
- package/dist/hud/__tests__/types.test.js +9 -0
- package/dist/hud/__tests__/types.test.js.map +1 -1
- package/dist/hud/__tests__/watch-detached.test.d.ts +2 -0
- package/dist/hud/__tests__/watch-detached.test.d.ts.map +1 -0
- package/dist/hud/__tests__/watch-detached.test.js +383 -0
- package/dist/hud/__tests__/watch-detached.test.js.map +1 -0
- package/dist/hud/__tests__/watch-realtmux.test.d.ts +2 -0
- package/dist/hud/__tests__/watch-realtmux.test.d.ts.map +1 -0
- package/dist/hud/__tests__/watch-realtmux.test.js +322 -0
- package/dist/hud/__tests__/watch-realtmux.test.js.map +1 -0
- package/dist/hud/index.d.ts +3 -0
- package/dist/hud/index.d.ts.map +1 -1
- package/dist/hud/index.js +98 -20
- package/dist/hud/index.js.map +1 -1
- package/dist/hud/reconcile.d.ts.map +1 -1
- package/dist/hud/reconcile.js +68 -24
- package/dist/hud/reconcile.js.map +1 -1
- package/dist/hud/render.d.ts.map +1 -1
- package/dist/hud/render.js +20 -0
- package/dist/hud/render.js.map +1 -1
- package/dist/hud/session-attached.d.ts +15 -0
- package/dist/hud/session-attached.d.ts.map +1 -0
- package/dist/hud/session-attached.js +47 -0
- package/dist/hud/session-attached.js.map +1 -0
- package/dist/hud/state.d.ts +9 -1
- package/dist/hud/state.d.ts.map +1 -1
- package/dist/hud/state.js +140 -3
- package/dist/hud/state.js.map +1 -1
- package/dist/hud/tmux.d.ts +4 -0
- package/dist/hud/tmux.d.ts.map +1 -1
- package/dist/hud/tmux.js +86 -23
- package/dist/hud/tmux.js.map +1 -1
- package/dist/hud/types.d.ts +20 -0
- package/dist/hud/types.d.ts.map +1 -1
- package/dist/hud/types.js +3 -0
- package/dist/hud/types.js.map +1 -1
- package/dist/modes/base.d.ts +15 -2
- package/dist/modes/base.d.ts.map +1 -1
- package/dist/modes/base.js +105 -16
- package/dist/modes/base.js.map +1 -1
- package/dist/question/autopilot-wait.js +2 -2
- package/dist/question/autopilot-wait.js.map +1 -1
- package/dist/question/deep-interview.js +2 -2
- package/dist/question/deep-interview.js.map +1 -1
- package/dist/ralplan/__tests__/advisory-activation.test.d.ts +2 -0
- package/dist/ralplan/__tests__/advisory-activation.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/advisory-activation.test.js +700 -0
- package/dist/ralplan/__tests__/advisory-activation.test.js.map +1 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.d.ts +2 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.js +88 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.js.map +1 -0
- package/dist/ralplan/__tests__/advisory.test.d.ts +2 -0
- package/dist/ralplan/__tests__/advisory.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/advisory.test.js +1087 -0
- package/dist/ralplan/__tests__/advisory.test.js.map +1 -0
- package/dist/ralplan/__tests__/runtime.test.js +416 -7
- package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
- package/dist/ralplan/advisory-activation-verifier.d.ts +19 -0
- package/dist/ralplan/advisory-activation-verifier.d.ts.map +1 -0
- package/dist/ralplan/advisory-activation-verifier.js +77 -0
- package/dist/ralplan/advisory-activation-verifier.js.map +1 -0
- package/dist/ralplan/advisory-activation.d.ts +34 -0
- package/dist/ralplan/advisory-activation.d.ts.map +1 -0
- package/dist/ralplan/advisory-activation.js +205 -0
- package/dist/ralplan/advisory-activation.js.map +1 -0
- package/dist/ralplan/advisory-contract.d.ts +103 -0
- package/dist/ralplan/advisory-contract.d.ts.map +1 -0
- package/dist/ralplan/advisory-contract.js +83 -0
- package/dist/ralplan/advisory-contract.js.map +1 -0
- package/dist/ralplan/advisory-detached-transition.d.ts +11 -0
- package/dist/ralplan/advisory-detached-transition.d.ts.map +1 -0
- package/dist/ralplan/advisory-detached-transition.js +21 -0
- package/dist/ralplan/advisory-detached-transition.js.map +1 -0
- package/dist/ralplan/advisory-evidence.d.ts +91 -0
- package/dist/ralplan/advisory-evidence.d.ts.map +1 -0
- package/dist/ralplan/advisory-evidence.js +393 -0
- package/dist/ralplan/advisory-evidence.js.map +1 -0
- package/dist/ralplan/advisory-lifecycle-validation.d.ts +11 -0
- package/dist/ralplan/advisory-lifecycle-validation.d.ts.map +1 -0
- package/dist/ralplan/advisory-lifecycle-validation.js +72 -0
- package/dist/ralplan/advisory-lifecycle-validation.js.map +1 -0
- package/dist/ralplan/advisory-postconditions.d.ts +3 -0
- package/dist/ralplan/advisory-postconditions.d.ts.map +1 -0
- package/dist/ralplan/advisory-postconditions.js +33 -0
- package/dist/ralplan/advisory-postconditions.js.map +1 -0
- package/dist/ralplan/advisory-recovery-journal.d.ts +56 -0
- package/dist/ralplan/advisory-recovery-journal.d.ts.map +1 -0
- package/dist/ralplan/advisory-recovery-journal.js +136 -0
- package/dist/ralplan/advisory-recovery-journal.js.map +1 -0
- package/dist/ralplan/advisory-storage.d.ts +24 -0
- package/dist/ralplan/advisory-storage.d.ts.map +1 -0
- package/dist/ralplan/advisory-storage.js +106 -0
- package/dist/ralplan/advisory-storage.js.map +1 -0
- package/dist/ralplan/advisory.d.ts +99 -0
- package/dist/ralplan/advisory.d.ts.map +1 -0
- package/dist/ralplan/advisory.js +914 -0
- package/dist/ralplan/advisory.js.map +1 -0
- package/dist/ralplan/documented-leader-preflight.d.ts +8 -0
- package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -1
- package/dist/ralplan/documented-leader-preflight.js +60 -2
- package/dist/ralplan/documented-leader-preflight.js.map +1 -1
- package/dist/ralplan/runtime-advisory-lifecycle.d.ts +74 -0
- package/dist/ralplan/runtime-advisory-lifecycle.d.ts.map +1 -0
- package/dist/ralplan/runtime-advisory-lifecycle.js +352 -0
- package/dist/ralplan/runtime-advisory-lifecycle.js.map +1 -0
- package/dist/ralplan/runtime-contract.d.ts +126 -0
- package/dist/ralplan/runtime-contract.d.ts.map +1 -0
- package/dist/ralplan/runtime-contract.js +2 -0
- package/dist/ralplan/runtime-contract.js.map +1 -0
- package/dist/ralplan/runtime.d.ts +6 -109
- package/dist/ralplan/runtime.d.ts.map +1 -1
- package/dist/ralplan/runtime.js +174 -56
- package/dist/ralplan/runtime.js.map +1 -1
- package/dist/scripts/__tests__/postinstall.test.js +26 -0
- package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.d.ts +2 -0
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.js +299 -0
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.js.map +1 -0
- package/dist/scripts/__tests__/smoke-packed-install.test.js +102 -8
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/codex-native-hook.d.ts +20 -0
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +226 -15
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/postinstall.d.ts +7 -0
- package/dist/scripts/postinstall.d.ts.map +1 -1
- package/dist/scripts/postinstall.js +40 -0
- package/dist/scripts/postinstall.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +21 -5
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +157 -57
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/state/__tests__/mode-binding-lease.test.d.ts +2 -0
- package/dist/state/__tests__/mode-binding-lease.test.d.ts.map +1 -0
- package/dist/state/__tests__/mode-binding-lease.test.js +498 -0
- package/dist/state/__tests__/mode-binding-lease.test.js.map +1 -0
- package/dist/state/__tests__/operations.test.js +1 -0
- package/dist/state/__tests__/operations.test.js.map +1 -1
- package/dist/state/__tests__/pinned-atomic-file.test.d.ts +2 -0
- package/dist/state/__tests__/pinned-atomic-file.test.d.ts.map +1 -0
- package/dist/state/__tests__/pinned-atomic-file.test.js +438 -0
- package/dist/state/__tests__/pinned-atomic-file.test.js.map +1 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.d.ts +2 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.d.ts.map +1 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.js +179 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.js.map +1 -0
- package/dist/state/__tests__/skill-active.test.js +29 -1
- package/dist/state/__tests__/skill-active.test.js.map +1 -1
- package/dist/state/mode-binding-lease-helper.d.ts +2 -0
- package/dist/state/mode-binding-lease-helper.d.ts.map +1 -0
- package/dist/state/mode-binding-lease-helper.js +623 -0
- package/dist/state/mode-binding-lease-helper.js.map +1 -0
- package/dist/state/mode-binding-lease.d.ts +25 -0
- package/dist/state/mode-binding-lease.d.ts.map +1 -0
- package/dist/state/mode-binding-lease.js +380 -0
- package/dist/state/mode-binding-lease.js.map +1 -0
- package/dist/state/operations.d.ts +29 -1
- package/dist/state/operations.d.ts.map +1 -1
- package/dist/state/operations.js +203 -122
- package/dist/state/operations.js.map +1 -1
- package/dist/state/pinned-atomic-file-darwin-client.d.ts +34 -0
- package/dist/state/pinned-atomic-file-darwin-client.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-client.js +174 -0
- package/dist/state/pinned-atomic-file-darwin-client.js.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-helper.d.ts +2 -0
- package/dist/state/pinned-atomic-file-darwin-helper.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-helper.js +275 -0
- package/dist/state/pinned-atomic-file-darwin-helper.js.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.d.ts +2 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.js +272 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.js.map +1 -0
- package/dist/state/pinned-atomic-file.d.ts +29 -0
- package/dist/state/pinned-atomic-file.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file.js +536 -0
- package/dist/state/pinned-atomic-file.js.map +1 -0
- package/dist/state/process-identity.d.ts +30 -0
- package/dist/state/process-identity.d.ts.map +1 -0
- package/dist/state/process-identity.js +99 -0
- package/dist/state/process-identity.js.map +1 -0
- package/dist/state/skill-active.d.ts +18 -1
- package/dist/state/skill-active.d.ts.map +1 -1
- package/dist/state/skill-active.js +82 -5
- package/dist/state/skill-active.js.map +1 -1
- package/dist/state/workflow-transition-reconcile.js +1 -1
- package/dist/state/workflow-transition-reconcile.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +109 -0
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +10 -1
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/utils/__tests__/file-durability.test.js +20 -0
- package/dist/utils/__tests__/file-durability.test.js.map +1 -1
- package/dist/utils/file-durability.d.ts +1 -1
- package/dist/utils/file-durability.d.ts.map +1 -1
- package/dist/utils/file-durability.js.map +1 -1
- package/dist/verification/__tests__/ci-rust-gates.test.js +12 -4
- package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
- package/dist/verification/__tests__/ci-trusted-publish.test.d.ts +2 -0
- package/dist/verification/__tests__/ci-trusted-publish.test.d.ts.map +1 -0
- package/dist/verification/__tests__/ci-trusted-publish.test.js +205 -0
- package/dist/verification/__tests__/ci-trusted-publish.test.js.map +1 -0
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js +10 -5
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
- package/dist/verification/__tests__/publish-npm-manual-retired.test.d.ts +2 -0
- package/dist/verification/__tests__/publish-npm-manual-retired.test.d.ts.map +1 -0
- package/dist/verification/__tests__/publish-npm-manual-retired.test.js +22 -0
- package/dist/verification/__tests__/publish-npm-manual-retired.test.js.map +1 -0
- package/package.json +1 -1
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/skills/doctor/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +10 -1
- package/plugins/oh-my-codex/skills/team/SKILL.md +2 -0
- package/skills/doctor/SKILL.md +1 -1
- package/skills/omx-setup/SKILL.md +1 -1
- package/skills/ralplan/SKILL.md +10 -1
- package/skills/team/SKILL.md +2 -0
- package/src/scripts/__tests__/postinstall.test.ts +28 -0
- package/src/scripts/__tests__/ralplan-advisory-hook.test.ts +320 -0
- package/src/scripts/__tests__/smoke-packed-install.test.ts +120 -7
- package/src/scripts/codex-native-hook.ts +220 -10
- package/src/scripts/postinstall.ts +46 -0
- package/src/scripts/smoke-packed-install.ts +175 -70
package/dist/cli/index.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Multi-agent orchestration for OpenAI Codex CLI
|
|
4
4
|
*/
|
|
5
5
|
import { execFileSync, spawn } from "child_process";
|
|
6
|
-
import { basename, dirname, isAbsolute, join, posix, relative, resolve, win32 } from "path";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "path";
|
|
7
7
|
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
8
|
-
import { copyFile, cp, lstat, mkdir, open, readFile, readdir, rm, stat, symlink, utimes, writeFile } from "fs/promises";
|
|
8
|
+
import { access, chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, symlink, utimes, writeFile } from "fs/promises";
|
|
9
9
|
import { constants as osConstants, homedir } from "os";
|
|
10
10
|
import { createHash, randomUUID } from "crypto";
|
|
11
11
|
import { setup, SETUP_MCP_MODES, SETUP_SCOPES, SETUP_TEAM_MODES, } from "./setup.js";
|
|
@@ -53,10 +53,11 @@ import { ROOT_REASONING_EFFORTS, isUnsupportedRootReasoningEffort, normalizeUnsu
|
|
|
53
53
|
export { readPersistedSetupPreferences, readPersistedSetupScope, resolveCodexConfigPathForLaunch, resolveCodexHomeForLaunch, resolveProjectLocalCodexHomeForLaunch, } from "./codex-home.js";
|
|
54
54
|
import { SKILL_ACTIVE_STATE_MODE, extractSessionIdFromInitializedStatePath, listActiveSkills, syncCanonicalSkillStateForMode, updateRootSkillActiveStateForStateDir, } from "../state/skill-active.js";
|
|
55
55
|
import { isTrackedWorkflowMode } from "../state/workflow-transition.js";
|
|
56
|
+
import { validateDetachedAdvisorySessionSkillTransition } from "../ralplan/advisory-detached-transition.js";
|
|
56
57
|
import { maybeCheckAndPromptUpdate, runImmediateUpdate } from "./update.js";
|
|
57
58
|
import { maybePromptGithubStar } from "./star-prompt.js";
|
|
58
59
|
import { generateOverlay, removeSessionModelInstructionsFile, resolveSessionOrchestrationMode, sessionModelInstructionsPath, writeSessionModelInstructionsFile, } from "../hooks/agents-overlay.js";
|
|
59
|
-
import { closeLaunchSessionBindingOnce, establishLaunchSessionBinding, readNativeSessionOwnerEvidence, readSessionPointer, readSessionState, resolveSessionPointerContext, finalizeBoundOnce, isSessionPointerLaunchAbort, normalizeSessionId, resetSessionMetrics, } from "../hooks/session.js";
|
|
60
|
+
import { closeLaunchSessionBindingOnce, establishLaunchSessionBinding, readNativeSessionOwnerEvidence, readSessionPointer, readSessionState, resolveSessionPointerContext, sessionOwnerFileIdentityMatches, finalizeBoundOnce, isSessionPointerLaunchAbort, normalizeSessionId, resetSessionMetrics, } from "../hooks/session.js";
|
|
60
61
|
import { probeActualTmuxInstanceEvidence, tmuxEvidenceBindsCandidate } from "../scripts/notify-hook/managed-tmux.js";
|
|
61
62
|
import { buildClientAttachedReconcileHookName, buildReconcileHudResizeArgs, buildRegisterClientAttachedReconcileArgs, buildRegisterResizeHookArgs, buildResizeHookName, buildResizeHookTarget, buildScheduleDelayedHudResizeArgs, buildUnregisterClientAttachedReconcileArgs, buildUnregisterResizeHookArgs, enableMouseScrolling, isMsysOrGitBash, isNativeWindows, isTmuxAvailable, } from "../team/tmux-session.js";
|
|
62
63
|
import { getPackageRoot } from "../utils/package.js";
|
|
@@ -633,10 +634,10 @@ export const CODEX_SQLITE_HOME_ENV = "CODEX_SQLITE_HOME";
|
|
|
633
634
|
export function runtimeCodexHomePath(cwd, sessionId) {
|
|
634
635
|
return join(omxRoot(cwd), "runtime", "codex-home", sessionId);
|
|
635
636
|
}
|
|
636
|
-
async function linkOrCopyCodexHomeEntry(source, destination) {
|
|
637
|
+
async function linkOrCopyCodexHomeEntry(source, destination, createSymlink = symlink) {
|
|
637
638
|
const stat = await lstat(source);
|
|
638
639
|
try {
|
|
639
|
-
await
|
|
640
|
+
await createSymlink(source, destination, stat.isDirectory() && process.platform === "win32" ? "junction" : undefined);
|
|
640
641
|
}
|
|
641
642
|
catch {
|
|
642
643
|
if (stat.isDirectory()) {
|
|
@@ -646,10 +647,116 @@ async function linkOrCopyCodexHomeEntry(source, destination) {
|
|
|
646
647
|
await copyFile(source, destination);
|
|
647
648
|
}
|
|
648
649
|
}
|
|
649
|
-
async function copyFilePreservingTimestamps(source, destination) {
|
|
650
|
-
await
|
|
651
|
-
const
|
|
652
|
-
|
|
650
|
+
async function copyFilePreservingTimestamps(source, destination, options = {}) {
|
|
651
|
+
const sourcePath = options.allowTopLevelSymlink === true ? await realpath(source) : source;
|
|
652
|
+
const sourceRoot = options.sourceRoot ? await realpath(options.sourceRoot) : undefined;
|
|
653
|
+
const destinationRoot = options.destinationRoot ? await realpath(options.destinationRoot) : undefined;
|
|
654
|
+
if (sourceRoot)
|
|
655
|
+
await assertHistoryPathWithin(sourcePath, sourceRoot);
|
|
656
|
+
if (destinationRoot)
|
|
657
|
+
await assertHistoryDestinationParentWithin(destination, destinationRoot);
|
|
658
|
+
const sourceHandle = await open(sourcePath, fsConstants.O_RDONLY | historyNoFollowFlag());
|
|
659
|
+
let destinationHandle;
|
|
660
|
+
const temporary = `${destination}.omx-history-${randomUUID()}`;
|
|
661
|
+
try {
|
|
662
|
+
const sourceStat = await sourceHandle.stat();
|
|
663
|
+
if (!sourceStat.isFile())
|
|
664
|
+
throw new Error(`history source is not a regular file: ${source}`);
|
|
665
|
+
if (options.expectedSourceStat && !hasSameHistoryIdentity(sourceStat, options.expectedSourceStat)) {
|
|
666
|
+
throw new HistoryEntryChangedError(`history source changed during copy: ${source}`);
|
|
667
|
+
}
|
|
668
|
+
await options.afterSourceOpen?.();
|
|
669
|
+
if (destinationRoot)
|
|
670
|
+
await assertHistoryDestinationParentWithin(destination, destinationRoot);
|
|
671
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
672
|
+
destinationHandle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | historyNoFollowFlag(), historyDestinationMode(sourceStat.mode));
|
|
673
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
674
|
+
let position = 0;
|
|
675
|
+
const copiedHash = createHash("sha256");
|
|
676
|
+
while (true) {
|
|
677
|
+
const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, position);
|
|
678
|
+
if (bytesRead === 0)
|
|
679
|
+
break;
|
|
680
|
+
copiedHash.update(buffer.subarray(0, bytesRead));
|
|
681
|
+
let written = 0;
|
|
682
|
+
while (written < bytesRead) {
|
|
683
|
+
const { bytesWritten } = await destinationHandle.write(buffer, written, bytesRead - written);
|
|
684
|
+
written += bytesWritten;
|
|
685
|
+
}
|
|
686
|
+
position += bytesRead;
|
|
687
|
+
}
|
|
688
|
+
const copiedSourceStat = await sourceHandle.stat();
|
|
689
|
+
if (!hasSameHistoryVersion(copiedSourceStat, sourceStat)) {
|
|
690
|
+
throw new HistoryEntryChangedError(`history source content changed during copy: ${source}`);
|
|
691
|
+
}
|
|
692
|
+
const verificationHash = createHash("sha256");
|
|
693
|
+
position = 0;
|
|
694
|
+
while (true) {
|
|
695
|
+
const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, position);
|
|
696
|
+
if (bytesRead === 0)
|
|
697
|
+
break;
|
|
698
|
+
verificationHash.update(buffer.subarray(0, bytesRead));
|
|
699
|
+
position += bytesRead;
|
|
700
|
+
}
|
|
701
|
+
if (verificationHash.digest("hex") !== copiedHash.digest("hex")) {
|
|
702
|
+
throw new HistoryEntryChangedError(`history source bytes changed during copy: ${source}`);
|
|
703
|
+
}
|
|
704
|
+
await destinationHandle.chmod(historyDestinationMode(sourceStat.mode));
|
|
705
|
+
await destinationHandle.utimes(sourceStat.atime, sourceStat.mtime);
|
|
706
|
+
await destinationHandle.close();
|
|
707
|
+
destinationHandle = undefined;
|
|
708
|
+
await options.afterStage?.(temporary, destination);
|
|
709
|
+
const stagedStat = await lstat(temporary);
|
|
710
|
+
if (!stagedStat.isFile() || stagedStat.isSymbolicLink() || stagedStat.nlink !== 1) {
|
|
711
|
+
throw new Error(`history staging path is not a regular file: ${temporary}`);
|
|
712
|
+
}
|
|
713
|
+
if (destinationRoot)
|
|
714
|
+
await assertHistoryDestinationParentWithin(destination, destinationRoot);
|
|
715
|
+
await publishHistoryReplacement(temporary, destination, false);
|
|
716
|
+
}
|
|
717
|
+
finally {
|
|
718
|
+
await destinationHandle?.close().catch(() => undefined);
|
|
719
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
720
|
+
await sourceHandle.close();
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function readHistoryFileWithoutSymlink(source, allowTopLevelSymlink = false, sourceRoot) {
|
|
724
|
+
const sourcePath = allowTopLevelSymlink === true ? await realpath(source) : source;
|
|
725
|
+
const canonicalSourceRoot = sourceRoot ? await realpath(sourceRoot) : undefined;
|
|
726
|
+
if (canonicalSourceRoot)
|
|
727
|
+
await assertHistoryPathWithin(sourcePath, canonicalSourceRoot);
|
|
728
|
+
const sourceHandle = await open(sourcePath, fsConstants.O_RDONLY | historyNoFollowFlag());
|
|
729
|
+
try {
|
|
730
|
+
const sourceStat = await sourceHandle.stat();
|
|
731
|
+
if (!sourceStat.isFile())
|
|
732
|
+
throw new Error(`history source is not a regular file: ${source}`);
|
|
733
|
+
return await sourceHandle.readFile("utf-8");
|
|
734
|
+
}
|
|
735
|
+
finally {
|
|
736
|
+
await sourceHandle.close();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function writeHistoryFileAtomically(destination, contents, mode, destinationRoot) {
|
|
740
|
+
const canonicalDestinationRoot = destinationRoot ? await realpath(destinationRoot) : undefined;
|
|
741
|
+
if (canonicalDestinationRoot)
|
|
742
|
+
await assertHistoryDestinationParentWithin(destination, canonicalDestinationRoot);
|
|
743
|
+
const destinationMode = historyDestinationMode(mode);
|
|
744
|
+
const temporary = `${destination}.omx-history-${randomUUID()}`;
|
|
745
|
+
let destinationHandle;
|
|
746
|
+
try {
|
|
747
|
+
destinationHandle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | historyNoFollowFlag(), destinationMode);
|
|
748
|
+
await destinationHandle.writeFile(contents, "utf-8");
|
|
749
|
+
await destinationHandle.chmod(destinationMode);
|
|
750
|
+
await destinationHandle.close();
|
|
751
|
+
destinationHandle = undefined;
|
|
752
|
+
if (canonicalDestinationRoot)
|
|
753
|
+
await assertHistoryDestinationParentWithin(destination, canonicalDestinationRoot);
|
|
754
|
+
await publishHistoryReplacement(temporary, destination, false);
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
await destinationHandle?.close().catch(() => undefined);
|
|
758
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
759
|
+
}
|
|
653
760
|
}
|
|
654
761
|
function isCodexSqliteArtifact(entryName) {
|
|
655
762
|
return /^(?:state|logs)_\d+\.sqlite(?:-(?:shm|wal))?$/.test(entryName);
|
|
@@ -680,6 +787,333 @@ function shouldMirrorProjectLaunchRuntimeEntry(entryName, includeHistoryArtifact
|
|
|
680
787
|
function shouldPersistProjectLaunchRuntimeEntry(entryName) {
|
|
681
788
|
return PROJECT_LAUNCH_PERSISTED_RUNTIME_ENTRY_NAMES.has(entryName);
|
|
682
789
|
}
|
|
790
|
+
function isRegularHistoryTarget(sourceStat) {
|
|
791
|
+
return sourceStat.isDirectory() || sourceStat.isFile();
|
|
792
|
+
}
|
|
793
|
+
export function historyDestinationMode(sourceMode) {
|
|
794
|
+
const permissions = sourceMode & 0o777;
|
|
795
|
+
return permissions | ((permissions & 0o070) << 3) | ((permissions & 0o007) << 6);
|
|
796
|
+
}
|
|
797
|
+
function historyNoFollowFlag() {
|
|
798
|
+
return process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
|
|
799
|
+
}
|
|
800
|
+
function isHistoryPathWithin(candidate, root) {
|
|
801
|
+
const normalizedRoot = process.platform === "win32" ? win32.normalize(root).toLowerCase() : root;
|
|
802
|
+
const normalizedCandidate = process.platform === "win32" ? win32.normalize(candidate).toLowerCase() : candidate;
|
|
803
|
+
const path = process.platform === "win32"
|
|
804
|
+
? win32.relative(normalizedRoot, normalizedCandidate)
|
|
805
|
+
: relative(normalizedRoot, normalizedCandidate);
|
|
806
|
+
const escapes = path === ".." || path.startsWith(`..${sep}`);
|
|
807
|
+
return path === "" || (!escapes && !isAbsolute(path));
|
|
808
|
+
}
|
|
809
|
+
async function assertHistoryPathWithin(path, root, allowMissing = false) {
|
|
810
|
+
const canonicalRoot = root;
|
|
811
|
+
const canonicalPath = await realpath(path).catch((error) => {
|
|
812
|
+
if (allowMissing && isUnresolvableHistoryLinkError(error))
|
|
813
|
+
return undefined;
|
|
814
|
+
throw error;
|
|
815
|
+
});
|
|
816
|
+
if (canonicalPath && !isHistoryPathWithin(canonicalPath, canonicalRoot)) {
|
|
817
|
+
throw new Error(`history path escapes its authorized root: ${path}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
async function assertHistoryDestinationParentWithin(destination, root) {
|
|
821
|
+
await assertHistoryPathWithin(dirname(destination), root);
|
|
822
|
+
}
|
|
823
|
+
async function assertHistoryDestinationDirectorySafe(destination, root) {
|
|
824
|
+
const destinationStat = await lstat(destination);
|
|
825
|
+
if (!destinationStat.isDirectory() || destinationStat.isSymbolicLink()) {
|
|
826
|
+
throw new HistoryEntryChangedError(`history destination changed during copy: ${destination}`);
|
|
827
|
+
}
|
|
828
|
+
await assertHistoryPathWithin(destination, root);
|
|
829
|
+
}
|
|
830
|
+
async function chmodHistoryDestination(destination, mode, tolerateForeignOwner) {
|
|
831
|
+
try {
|
|
832
|
+
await chmod(destination, mode);
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
if (tolerateForeignOwner && error.code === "EPERM")
|
|
836
|
+
return;
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
async function utimesHistoryDestination(destination, atime, mtime, tolerateForeignOwner) {
|
|
841
|
+
try {
|
|
842
|
+
await utimes(destination, atime, mtime);
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
if (tolerateForeignOwner && error.code === "EPERM")
|
|
846
|
+
return;
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async function publishHistoryReplacement(temporary, destination, recursive) {
|
|
851
|
+
try {
|
|
852
|
+
await rename(temporary, destination);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
const code = error.code;
|
|
857
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "ENOTEMPTY")
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
const backup = `${destination}.omx-history-backup-${randomUUID()}`;
|
|
861
|
+
await rename(destination, backup);
|
|
862
|
+
try {
|
|
863
|
+
await rename(temporary, destination);
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
await rename(backup, destination).catch(() => undefined);
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
await rm(backup, { recursive, force: true }).catch(() => undefined);
|
|
870
|
+
}
|
|
871
|
+
function isUnresolvableHistoryLinkError(error) {
|
|
872
|
+
if (!error || typeof error !== "object")
|
|
873
|
+
return false;
|
|
874
|
+
const code = error.code;
|
|
875
|
+
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
|
876
|
+
}
|
|
877
|
+
class HistoryEntryChangedError extends Error {
|
|
878
|
+
code = "ESTALE";
|
|
879
|
+
}
|
|
880
|
+
function isDiscardableHistoryCopyError(error) {
|
|
881
|
+
return isUnresolvableHistoryLinkError(error) || error instanceof HistoryEntryChangedError;
|
|
882
|
+
}
|
|
883
|
+
const HISTORY_PERSISTENCE_LOCK_TIMEOUT_MS = 5_000;
|
|
884
|
+
const HISTORY_PERSISTENCE_LOCK_STALE_MS = 30_000;
|
|
885
|
+
const HISTORY_PERSISTENCE_LOCK_HEARTBEAT_MS = 5_000;
|
|
886
|
+
const HISTORY_PERSISTENCE_LOCK_RENAME_RETRIES = 20;
|
|
887
|
+
const HISTORY_PERSISTENCE_LOCK_RENAME_RETRY_DELAY_MS = 10;
|
|
888
|
+
async function readHistoryPersistenceLockOwner(lockPath) {
|
|
889
|
+
try {
|
|
890
|
+
const raw = await readFile(join(lockPath, "owner.json"), "utf-8");
|
|
891
|
+
const parts = raw.split("\n");
|
|
892
|
+
const completeLines = raw.endsWith("\n") || parts.length === 1 ? parts.filter(Boolean) : parts.slice(0, -1).filter(Boolean);
|
|
893
|
+
const owner = JSON.parse(completeLines.at(-1) ?? "");
|
|
894
|
+
if (typeof owner.token !== "string" || typeof owner.pid !== "number")
|
|
895
|
+
return null;
|
|
896
|
+
if (owner.startIdentity !== undefined && typeof owner.startIdentity !== "string")
|
|
897
|
+
return null;
|
|
898
|
+
return { token: owner.token, pid: owner.pid, startIdentity: owner.startIdentity };
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
if (error.code === "ENOENT")
|
|
902
|
+
return undefined;
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
async function historyProcessStartIdentity(pid) {
|
|
907
|
+
if (process.platform !== "linux")
|
|
908
|
+
return undefined;
|
|
909
|
+
try {
|
|
910
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf-8");
|
|
911
|
+
const fields = stat.slice(stat.lastIndexOf(") ") + 2).split(" ");
|
|
912
|
+
return fields[19] ? `${pid}:${fields[19]}` : undefined;
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
return undefined;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async function isHistoryProcessAlive(owner) {
|
|
919
|
+
try {
|
|
920
|
+
process.kill(owner.pid, 0);
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
if (error.code === "ESRCH")
|
|
924
|
+
return false;
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
if (!owner.startIdentity)
|
|
928
|
+
return undefined;
|
|
929
|
+
const currentIdentity = await historyProcessStartIdentity(owner.pid);
|
|
930
|
+
if (!currentIdentity)
|
|
931
|
+
return undefined;
|
|
932
|
+
return currentIdentity === owner.startIdentity;
|
|
933
|
+
}
|
|
934
|
+
async function historyLockIsCurrent(lease) {
|
|
935
|
+
const lockStat = await lstat(lease.lockPath).catch(() => undefined);
|
|
936
|
+
if (!lockStat || !lockStat.isDirectory() || lockStat.isSymbolicLink())
|
|
937
|
+
return false;
|
|
938
|
+
if (!hasSameHistoryIdentity(lockStat, lease.lockIdentity))
|
|
939
|
+
return false;
|
|
940
|
+
const owner = await readHistoryPersistenceLockOwner(lease.lockPath);
|
|
941
|
+
return owner?.token === lease.token;
|
|
942
|
+
}
|
|
943
|
+
async function retireHistoryLock(lockPath, root, expectedIdentity, expectedToken) {
|
|
944
|
+
const currentStat = await lstat(lockPath).catch(() => undefined);
|
|
945
|
+
if (!currentStat || !currentStat.isDirectory() || currentStat.isSymbolicLink())
|
|
946
|
+
return;
|
|
947
|
+
if (!hasSameHistoryIdentity(currentStat, expectedIdentity))
|
|
948
|
+
return;
|
|
949
|
+
if (expectedToken) {
|
|
950
|
+
const owner = await readHistoryPersistenceLockOwner(lockPath);
|
|
951
|
+
if (owner?.token !== expectedToken)
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const retiredPath = `${lockPath}.retired-${randomUUID()}`;
|
|
955
|
+
let retired = false;
|
|
956
|
+
for (let attempt = 0; attempt < HISTORY_PERSISTENCE_LOCK_RENAME_RETRIES; attempt += 1) {
|
|
957
|
+
try {
|
|
958
|
+
await rename(lockPath, retiredPath);
|
|
959
|
+
retired = true;
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
if (attempt + 1 < HISTORY_PERSISTENCE_LOCK_RENAME_RETRIES) {
|
|
964
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, HISTORY_PERSISTENCE_LOCK_RENAME_RETRY_DELAY_MS));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (!retired)
|
|
969
|
+
return;
|
|
970
|
+
await assertHistoryPathWithin(retiredPath, root);
|
|
971
|
+
await rm(retiredPath, { recursive: true, force: true }).catch(() => undefined);
|
|
972
|
+
}
|
|
973
|
+
export async function releaseHistoryPersistenceLock(lease) {
|
|
974
|
+
clearInterval(lease.heartbeat);
|
|
975
|
+
await lease.waitForHeartbeat();
|
|
976
|
+
if (!(await historyLockIsCurrent(lease)))
|
|
977
|
+
return;
|
|
978
|
+
await retireHistoryLock(lease.lockPath, lease.root, lease.lockIdentity, lease.token);
|
|
979
|
+
}
|
|
980
|
+
export async function acquireHistoryPersistenceLock(root, options = {}) {
|
|
981
|
+
root = await realpath(root);
|
|
982
|
+
const lockPath = join(root, ".omx-history.lock");
|
|
983
|
+
const ownerPath = join(lockPath, "owner.json");
|
|
984
|
+
const timeoutMs = options.timeoutMs ?? HISTORY_PERSISTENCE_LOCK_TIMEOUT_MS;
|
|
985
|
+
const staleMs = options.staleMs ?? HISTORY_PERSISTENCE_LOCK_STALE_MS;
|
|
986
|
+
const heartbeatMs = options.heartbeatMs ?? HISTORY_PERSISTENCE_LOCK_HEARTBEAT_MS;
|
|
987
|
+
const deadline = Date.now() + timeoutMs;
|
|
988
|
+
while (true) {
|
|
989
|
+
try {
|
|
990
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
991
|
+
const lockStat = await lstat(lockPath);
|
|
992
|
+
if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) {
|
|
993
|
+
throw new Error(`history persistence lock is not a private directory: ${lockPath}`);
|
|
994
|
+
}
|
|
995
|
+
await assertHistoryPathWithin(lockPath, root);
|
|
996
|
+
const lockIdentity = { dev: lockStat.dev, ino: lockStat.ino };
|
|
997
|
+
if (process.platform !== "win32" && (lockStat.mode & 0o077) !== 0) {
|
|
998
|
+
throw new Error(`history persistence lock is not private: ${lockPath}`);
|
|
999
|
+
}
|
|
1000
|
+
const token = randomUUID();
|
|
1001
|
+
const startIdentity = await historyProcessStartIdentity(process.pid);
|
|
1002
|
+
const initialOwnerPath = `${ownerPath}.${randomUUID()}.tmp`;
|
|
1003
|
+
await writeFile(initialOwnerPath, `${JSON.stringify({ token, pid: process.pid, startIdentity, heartbeatAt: Date.now() })}\n`, { flag: "wx", mode: 0o600 });
|
|
1004
|
+
await rename(initialOwnerPath, ownerPath);
|
|
1005
|
+
let heartbeatInFlight = Promise.resolve();
|
|
1006
|
+
const runHeartbeat = async () => {
|
|
1007
|
+
let temporaryOwnerPath;
|
|
1008
|
+
try {
|
|
1009
|
+
const owner = await readHistoryPersistenceLockOwner(lockPath);
|
|
1010
|
+
if (owner?.token !== token)
|
|
1011
|
+
return;
|
|
1012
|
+
if (process.platform === "win32") {
|
|
1013
|
+
const currentLockStat = await lstat(lockPath);
|
|
1014
|
+
if (!hasSameHistoryIdentity(currentLockStat, lockIdentity))
|
|
1015
|
+
return;
|
|
1016
|
+
await utimes(lockPath, new Date(), new Date());
|
|
1017
|
+
await writeFile(ownerPath, `${JSON.stringify({ token, pid: process.pid, startIdentity, heartbeatAt: Date.now() })}\n`, { flag: "a" });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
temporaryOwnerPath = `${ownerPath}.${randomUUID()}.tmp`;
|
|
1021
|
+
await writeFile(temporaryOwnerPath, `${JSON.stringify({ token, pid: process.pid, startIdentity, heartbeatAt: Date.now() })}\n`, { flag: "wx", mode: 0o600 });
|
|
1022
|
+
const currentLockStat = await lstat(lockPath);
|
|
1023
|
+
const currentOwner = await readHistoryPersistenceLockOwner(lockPath);
|
|
1024
|
+
if (!hasSameHistoryIdentity(currentLockStat, lockIdentity) || currentOwner?.token !== token) {
|
|
1025
|
+
await rm(temporaryOwnerPath, { force: true }).catch(() => undefined);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
await rename(temporaryOwnerPath, ownerPath);
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
// A contender may have removed a dead owner's lock between checks.
|
|
1032
|
+
if (temporaryOwnerPath)
|
|
1033
|
+
await rm(temporaryOwnerPath, { force: true }).catch(() => undefined);
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
const heartbeat = setInterval(() => {
|
|
1037
|
+
heartbeatInFlight = heartbeatInFlight.then(runHeartbeat, runHeartbeat);
|
|
1038
|
+
}, heartbeatMs);
|
|
1039
|
+
heartbeat.unref?.();
|
|
1040
|
+
return { root, lockPath, token, lockIdentity, heartbeat, waitForHeartbeat: () => heartbeatInFlight };
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
if (error.code !== "EEXIST")
|
|
1044
|
+
throw error;
|
|
1045
|
+
const lockStat = await lstat(lockPath).catch(() => undefined);
|
|
1046
|
+
if (!lockStat)
|
|
1047
|
+
continue;
|
|
1048
|
+
if (lockStat?.isSymbolicLink())
|
|
1049
|
+
throw new Error(`history persistence lock is a symlink: ${lockPath}`);
|
|
1050
|
+
const owner = await readHistoryPersistenceLockOwner(lockPath);
|
|
1051
|
+
const lockAge = lockStat ? Date.now() - lockStat.mtimeMs : 0;
|
|
1052
|
+
if (owner && owner !== null && (await isHistoryProcessAlive(owner)) === false) {
|
|
1053
|
+
const ownerStat = await stat(join(lockPath, "owner.json")).catch(() => undefined);
|
|
1054
|
+
if (ownerStat && Date.now() - ownerStat.mtimeMs > staleMs) {
|
|
1055
|
+
await retireHistoryLock(lockPath, root, lockStat ? { dev: lockStat.dev, ino: lockStat.ino } : { dev: -1, ino: -1 }, owner.token);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
else if (owner === undefined && lockAge > staleMs) {
|
|
1060
|
+
await retireHistoryLock(lockPath, root, lockStat ? { dev: lockStat.dev, ino: lockStat.ino } : { dev: -1, ino: -1 });
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (process.platform !== "win32" && (lockStat.mode & 0o077) !== 0) {
|
|
1064
|
+
throw new Error(`history persistence lock is not private: ${lockPath}`);
|
|
1065
|
+
}
|
|
1066
|
+
if (Date.now() >= deadline)
|
|
1067
|
+
throw new Error(`timed out waiting for history persistence lock: ${lockPath}`);
|
|
1068
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
function hasSameHistoryIdentity(actual, expected) {
|
|
1073
|
+
return actual.dev === expected.dev && actual.ino === expected.ino;
|
|
1074
|
+
}
|
|
1075
|
+
function hasSameHistoryVersion(actual, expected) {
|
|
1076
|
+
return hasSameHistoryIdentity(actual, expected) && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs;
|
|
1077
|
+
}
|
|
1078
|
+
async function inspectProjectLaunchRuntimeHistoryEntry(source) {
|
|
1079
|
+
// Durable history names are an explicit allowlist. Follow only the named
|
|
1080
|
+
// entry's top-level link so a directory link is classified as a directory;
|
|
1081
|
+
// the destination remains the fixed runtime history path below.
|
|
1082
|
+
let linkStat;
|
|
1083
|
+
try {
|
|
1084
|
+
linkStat = await lstat(source);
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1088
|
+
return undefined;
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
if (!linkStat.isSymbolicLink())
|
|
1092
|
+
return { linkStat, targetStat: linkStat, targetRealpath: undefined };
|
|
1093
|
+
try {
|
|
1094
|
+
return { linkStat, targetStat: await stat(source), targetRealpath: await realpath(source) };
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1098
|
+
return { linkStat, targetStat: undefined };
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async function historyEntryStillMatches(source, inspection) {
|
|
1103
|
+
if (!inspection)
|
|
1104
|
+
return false;
|
|
1105
|
+
const current = await inspectProjectLaunchRuntimeHistoryEntry(source);
|
|
1106
|
+
const inspectedTarget = inspection.targetStat;
|
|
1107
|
+
const currentTarget = current?.targetStat;
|
|
1108
|
+
if (!current || !inspectedTarget || !currentTarget)
|
|
1109
|
+
return false;
|
|
1110
|
+
if (current.linkStat.dev !== inspection.linkStat.dev || current.linkStat.ino !== inspection.linkStat.ino)
|
|
1111
|
+
return false;
|
|
1112
|
+
if (inspection.targetRealpath !== current.targetRealpath)
|
|
1113
|
+
return false;
|
|
1114
|
+
return (currentTarget.dev === inspectedTarget.dev &&
|
|
1115
|
+
currentTarget.ino === inspectedTarget.ino);
|
|
1116
|
+
}
|
|
683
1117
|
function uniqueJsonlLines(contents) {
|
|
684
1118
|
const seen = new Set();
|
|
685
1119
|
const lines = [];
|
|
@@ -691,84 +1125,366 @@ function uniqueJsonlLines(contents) {
|
|
|
691
1125
|
}
|
|
692
1126
|
return lines;
|
|
693
1127
|
}
|
|
694
|
-
async function persistProjectLaunchRuntimeJsonlArtifact(source, destination) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1128
|
+
async function persistProjectLaunchRuntimeJsonlArtifact(source, destination, sourceRoot, destinationRoot, sourceMode, expectedSourceStat) {
|
|
1129
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1130
|
+
const destinationInspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
|
|
1131
|
+
if (destinationInspection && !destinationInspection.targetStat?.isFile())
|
|
1132
|
+
return;
|
|
1133
|
+
const destinationPath = destinationInspection?.targetRealpath ?? destination;
|
|
1134
|
+
await assertHistoryPathWithin(destinationPath, destinationRoot, true);
|
|
1135
|
+
const destinationVersion = destinationInspection?.targetStat;
|
|
1136
|
+
const existing = destinationInspection ? await readHistoryFileWithoutSymlink(destinationPath, false, destinationRoot) : "";
|
|
1137
|
+
const sourceContents = await readHistoryFileWithoutSymlink(source, false, sourceRoot);
|
|
1138
|
+
const sourceStat = await stat(source);
|
|
1139
|
+
if (!hasSameHistoryVersion(sourceStat, expectedSourceStat)) {
|
|
1140
|
+
throw new HistoryEntryChangedError(`history source changed during JSONL read: ${source}`);
|
|
1141
|
+
}
|
|
1142
|
+
const latestDestinationInspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
|
|
1143
|
+
const latestDestinationVersion = latestDestinationInspection?.targetStat;
|
|
1144
|
+
if ((destinationVersion && !latestDestinationVersion) ||
|
|
1145
|
+
(!destinationVersion && latestDestinationVersion) ||
|
|
1146
|
+
(destinationVersion && latestDestinationVersion && !hasSameHistoryVersion(latestDestinationVersion, destinationVersion))) {
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const separator = existing === "" || existing.endsWith("\n") || sourceContents === "" ? "" : "\n";
|
|
1150
|
+
const lines = uniqueJsonlLines(`${existing}${separator}${sourceContents}`);
|
|
1151
|
+
const mode = destinationInspection?.targetStat?.mode ?? historyDestinationMode(sourceMode);
|
|
1152
|
+
await writeHistoryFileAtomically(destinationPath, lines.length > 0 ? `${lines.join("\n")}\n` : "", mode, destinationRoot);
|
|
705
1153
|
return;
|
|
706
|
-
|
|
1154
|
+
}
|
|
1155
|
+
throw new HistoryEntryChangedError(`history destination changed during JSONL persistence: ${destination}`);
|
|
1156
|
+
}
|
|
1157
|
+
async function resolveHistoryPersistenceLockRoot(projectCodexHome, destinationRoot) {
|
|
1158
|
+
const candidates = [];
|
|
707
1159
|
for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
|
|
708
|
-
const source = join(runtimeCodexHome, entryName);
|
|
709
|
-
if (!existsSync(source))
|
|
710
|
-
continue;
|
|
711
|
-
const sourceStat = await lstat(source);
|
|
712
|
-
if (sourceStat.isSymbolicLink())
|
|
713
|
-
continue;
|
|
714
1160
|
const destination = join(projectCodexHome, entryName);
|
|
715
|
-
|
|
716
|
-
|
|
1161
|
+
const inspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
|
|
1162
|
+
const targetStat = inspection?.targetStat;
|
|
1163
|
+
const target = inspection?.targetRealpath ?? (targetStat ? await realpath(destination).catch(() => undefined) : undefined);
|
|
1164
|
+
if (!target)
|
|
717
1165
|
continue;
|
|
1166
|
+
const anchors = [dirname(target), ...(targetStat?.isDirectory() ? [target] : [])];
|
|
1167
|
+
for (const anchor of anchors) {
|
|
1168
|
+
const canonicalAnchor = await realpath(anchor).catch(() => undefined);
|
|
1169
|
+
if (!canonicalAnchor)
|
|
1170
|
+
continue;
|
|
1171
|
+
if (await access(canonicalAnchor, fsConstants.W_OK).then(() => true, () => false)) {
|
|
1172
|
+
candidates.push(canonicalAnchor);
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
718
1175
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1176
|
+
}
|
|
1177
|
+
return (candidates.length > 0 ? [...new Set(candidates)] : [destinationRoot]).sort();
|
|
1178
|
+
}
|
|
1179
|
+
async function withHistoryPersistenceLocks(roots, action) {
|
|
1180
|
+
const leases = [];
|
|
1181
|
+
try {
|
|
1182
|
+
for (const root of [...new Set(roots)].sort())
|
|
1183
|
+
leases.push(await acquireHistoryPersistenceLock(root));
|
|
1184
|
+
return await action();
|
|
1185
|
+
}
|
|
1186
|
+
finally {
|
|
1187
|
+
for (const lease of leases.reverse())
|
|
1188
|
+
await releaseHistoryPersistenceLock(lease);
|
|
726
1189
|
}
|
|
727
1190
|
}
|
|
728
|
-
async function
|
|
1191
|
+
async function persistProjectLaunchRuntimeHistoryArtifacts(runtimeCodexHome, projectCodexHome, expectedRuntimeStat) {
|
|
1192
|
+
if (!runtimeCodexHome || !projectCodexHome)
|
|
1193
|
+
return true;
|
|
1194
|
+
if (!existsSync(runtimeCodexHome))
|
|
1195
|
+
return true;
|
|
1196
|
+
await mkdir(projectCodexHome, { recursive: true });
|
|
1197
|
+
const destinationRoot = await realpath(projectCodexHome);
|
|
1198
|
+
const lockRoots = await resolveHistoryPersistenceLockRoot(projectCodexHome, destinationRoot);
|
|
1199
|
+
return withHistoryPersistenceLocks(lockRoots, async () => {
|
|
1200
|
+
let complete = true;
|
|
1201
|
+
for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
|
|
1202
|
+
if (expectedRuntimeStat) {
|
|
1203
|
+
const runtimeStat = await lstat(runtimeCodexHome).catch(() => undefined);
|
|
1204
|
+
if (!runtimeStat || !hasSameHistoryIdentity(runtimeStat, expectedRuntimeStat)) {
|
|
1205
|
+
complete = false;
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
const source = join(runtimeCodexHome, entryName);
|
|
1210
|
+
const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
|
|
1211
|
+
if (!sourceInspection?.targetStat || !isRegularHistoryTarget(sourceInspection.targetStat))
|
|
1212
|
+
continue;
|
|
1213
|
+
const sourcePath = sourceInspection.targetRealpath ?? source;
|
|
1214
|
+
const sourceRoot = sourceInspection.targetRealpath ?? source;
|
|
1215
|
+
if (!(await historyEntryStillMatches(source, sourceInspection))) {
|
|
1216
|
+
complete = false;
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
const destination = join(projectCodexHome, entryName);
|
|
1220
|
+
const destinationInspection = await inspectProjectLaunchRuntimeHistoryEntry(destination);
|
|
1221
|
+
if (destinationInspection && !destinationInspection.targetStat) {
|
|
1222
|
+
complete = false;
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
if (destinationInspection && !isRegularHistoryTarget(destinationInspection.targetStat)) {
|
|
1226
|
+
complete = false;
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
const destinationPath = destinationInspection?.targetRealpath ?? destination;
|
|
1230
|
+
const sourceCanonical = await realpath(sourcePath).catch((error) => {
|
|
1231
|
+
if (isDiscardableHistoryCopyError(error))
|
|
1232
|
+
return undefined;
|
|
1233
|
+
throw error;
|
|
1234
|
+
});
|
|
1235
|
+
const destinationCanonical = await realpath(destinationPath).catch((error) => {
|
|
1236
|
+
if (isDiscardableHistoryCopyError(error))
|
|
1237
|
+
return undefined;
|
|
1238
|
+
throw error;
|
|
1239
|
+
});
|
|
1240
|
+
if (sourceCanonical && destinationCanonical && sourceCanonical === destinationCanonical)
|
|
1241
|
+
continue;
|
|
1242
|
+
await assertHistoryPathWithin(destinationPath, destinationRoot, true);
|
|
1243
|
+
if (sourceInspection.targetStat.isDirectory()) {
|
|
1244
|
+
try {
|
|
1245
|
+
await copyProjectLaunchRuntimeHistoryDirectory(sourcePath, destinationPath, false, sourceRoot, destinationRoot, sourceInspection.targetStat);
|
|
1246
|
+
}
|
|
1247
|
+
catch (error) {
|
|
1248
|
+
if (isDiscardableHistoryCopyError(error)) {
|
|
1249
|
+
complete = false;
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
throw error;
|
|
1253
|
+
}
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
if (entryName === "history.jsonl" || entryName === "session_index.jsonl") {
|
|
1257
|
+
try {
|
|
1258
|
+
await persistProjectLaunchRuntimeJsonlArtifact(sourcePath, destinationPath, sourceRoot, destinationRoot, sourceInspection.targetStat.mode, sourceInspection.targetStat);
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
if (isDiscardableHistoryCopyError(error)) {
|
|
1262
|
+
complete = false;
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
throw error;
|
|
1266
|
+
}
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (sourceInspection.targetStat.isFile()) {
|
|
1270
|
+
try {
|
|
1271
|
+
await copyFilePreservingTimestamps(sourcePath, destinationPath, {
|
|
1272
|
+
sourceRoot,
|
|
1273
|
+
destinationRoot,
|
|
1274
|
+
expectedSourceStat: sourceInspection.targetStat,
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
catch (error) {
|
|
1278
|
+
if (isDiscardableHistoryCopyError(error)) {
|
|
1279
|
+
complete = false;
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
throw error;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return complete;
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
async function ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome, skipEntryNames = new Set(), createSymlink = symlink) {
|
|
729
1290
|
await mkdir(projectCodexHome, { recursive: true });
|
|
730
1291
|
for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
|
|
1292
|
+
if (skipEntryNames.has(entryName))
|
|
1293
|
+
continue;
|
|
731
1294
|
const runtimeEntry = join(runtimeCodexHome, entryName);
|
|
732
1295
|
if (existsSync(runtimeEntry))
|
|
733
1296
|
continue;
|
|
734
1297
|
const projectEntry = join(projectCodexHome, entryName);
|
|
1298
|
+
const projectEntryInspection = await inspectProjectLaunchRuntimeHistoryEntry(projectEntry);
|
|
1299
|
+
if (projectEntryInspection &&
|
|
1300
|
+
(!projectEntryInspection.targetStat || !isRegularHistoryTarget(projectEntryInspection.targetStat))) {
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
735
1303
|
if (entryName === "sessions") {
|
|
736
1304
|
await mkdir(projectEntry, { recursive: true });
|
|
737
1305
|
}
|
|
738
1306
|
else if (!existsSync(projectEntry)) {
|
|
739
1307
|
await writeFile(projectEntry, "");
|
|
740
1308
|
}
|
|
741
|
-
await linkOrCopyCodexHomeEntry(projectEntry, runtimeEntry);
|
|
1309
|
+
await linkOrCopyCodexHomeEntry(projectEntry, runtimeEntry, createSymlink);
|
|
742
1310
|
}
|
|
743
1311
|
}
|
|
744
|
-
async function materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceCodexHome) {
|
|
1312
|
+
async function materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceCodexHome, options = {}) {
|
|
1313
|
+
const skipEntryNames = new Set();
|
|
1314
|
+
const destinationRoot = await realpath(runtimeCodexHome);
|
|
745
1315
|
for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
|
|
746
1316
|
const source = join(sourceCodexHome, entryName);
|
|
747
|
-
|
|
1317
|
+
const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
|
|
1318
|
+
if (!sourceInspection || !sourceInspection.targetStat)
|
|
1319
|
+
continue;
|
|
1320
|
+
if (options.onlySymlinks === true && !sourceInspection.linkStat.isSymbolicLink())
|
|
748
1321
|
continue;
|
|
749
1322
|
const destination = join(runtimeCodexHome, entryName);
|
|
1323
|
+
await options.beforeCopy?.(entryName, source, destination);
|
|
1324
|
+
if (!(await historyEntryStillMatches(source, sourceInspection))) {
|
|
1325
|
+
skipEntryNames.add(entryName);
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
await options.afterValidation?.(entryName, source, destination);
|
|
1329
|
+
if (!(await historyEntryStillMatches(source, sourceInspection))) {
|
|
1330
|
+
skipEntryNames.add(entryName);
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
try {
|
|
1334
|
+
const sourceStat = sourceInspection.targetStat;
|
|
1335
|
+
if (!sourceStat)
|
|
1336
|
+
continue;
|
|
1337
|
+
const sourcePath = sourceInspection.targetRealpath ?? source;
|
|
1338
|
+
if (sourceStat.isDirectory()) {
|
|
1339
|
+
await replaceProjectLaunchRuntimeHistoryDirectory(sourcePath, destination, false, destinationRoot, sourceStat, options.afterStage
|
|
1340
|
+
? (temporary, stagedDestination) => options.afterStage(entryName, temporary, stagedDestination)
|
|
1341
|
+
: undefined);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
if (sourceStat.isFile()) {
|
|
1345
|
+
await copyFilePreservingTimestamps(sourcePath, destination, {
|
|
1346
|
+
allowTopLevelSymlink: false,
|
|
1347
|
+
sourceRoot: sourcePath,
|
|
1348
|
+
destinationRoot,
|
|
1349
|
+
expectedSourceStat: sourceStat,
|
|
1350
|
+
afterStage: options.afterStage
|
|
1351
|
+
? (temporary, stagedDestination) => options.afterStage(entryName, temporary, stagedDestination)
|
|
1352
|
+
: undefined,
|
|
1353
|
+
afterSourceOpen: options.afterSourceOpen
|
|
1354
|
+
? () => options.afterSourceOpen(entryName, sourcePath)
|
|
1355
|
+
: undefined,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
catch (error) {
|
|
1360
|
+
if (isDiscardableHistoryCopyError(error)) {
|
|
1361
|
+
skipEntryNames.add(entryName);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
throw error;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return skipEntryNames;
|
|
1368
|
+
}
|
|
1369
|
+
async function copyProjectLaunchRuntimeHistoryDirectory(source, destination, allowTopLevelSymlink = false, sourceRoot = source, destinationRoot = destination, expectedSourceStat) {
|
|
1370
|
+
sourceRoot = await realpath(sourceRoot);
|
|
1371
|
+
destinationRoot = await realpath(destinationRoot);
|
|
1372
|
+
const sourceStat = allowTopLevelSymlink ? await stat(source) : await lstat(source);
|
|
1373
|
+
if (!sourceStat.isDirectory() || (!allowTopLevelSymlink && sourceStat.isSymbolicLink()))
|
|
1374
|
+
return;
|
|
1375
|
+
if (expectedSourceStat && !hasSameHistoryIdentity(sourceStat, expectedSourceStat)) {
|
|
1376
|
+
throw new HistoryEntryChangedError(`history source changed during directory copy: ${source}`);
|
|
1377
|
+
}
|
|
1378
|
+
await assertHistoryPathWithin(source, sourceRoot);
|
|
1379
|
+
const destinationStat = await lstat(destination).catch((error) => {
|
|
1380
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1381
|
+
return undefined;
|
|
1382
|
+
throw error;
|
|
1383
|
+
});
|
|
1384
|
+
if (destinationStat && (!destinationStat.isDirectory() || destinationStat.isSymbolicLink())) {
|
|
750
1385
|
await rm(destination, { recursive: true, force: true });
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1386
|
+
}
|
|
1387
|
+
await assertHistoryDestinationParentWithin(destination, destinationRoot);
|
|
1388
|
+
const destinationMode = destinationStat && destinationStat.isDirectory() && !destinationStat.isSymbolicLink()
|
|
1389
|
+
? destinationStat.mode & historyDestinationMode(sourceStat.mode) & 0o7777
|
|
1390
|
+
: undefined;
|
|
1391
|
+
const destinationWasExistingDirectory = Boolean(destinationStat && destinationStat.isDirectory() && !destinationStat.isSymbolicLink());
|
|
1392
|
+
const sourceDestinationMode = historyDestinationMode(sourceStat.mode);
|
|
1393
|
+
await mkdir(destination, { recursive: true, mode: destinationMode ?? sourceDestinationMode });
|
|
1394
|
+
await assertHistoryDestinationDirectorySafe(destination, destinationRoot);
|
|
1395
|
+
await chmodHistoryDestination(destination, (destinationMode ?? sourceDestinationMode) | 0o700, destinationWasExistingDirectory);
|
|
1396
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
1397
|
+
if (entry.name === ".omx-history.lock" || entry.name.startsWith(".omx-history.lock."))
|
|
1398
|
+
continue;
|
|
1399
|
+
const sourceEntry = join(source, entry.name);
|
|
1400
|
+
const destinationEntry = join(destination, entry.name);
|
|
1401
|
+
const entryStat = await lstat(sourceEntry).catch((error) => {
|
|
1402
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1403
|
+
return undefined;
|
|
1404
|
+
throw error;
|
|
1405
|
+
});
|
|
1406
|
+
if (!entryStat || entryStat.isSymbolicLink())
|
|
754
1407
|
continue;
|
|
1408
|
+
if (entryStat.isDirectory()) {
|
|
1409
|
+
await copyProjectLaunchRuntimeHistoryDirectory(sourceEntry, destinationEntry, false, sourceRoot, destinationRoot, entryStat);
|
|
1410
|
+
}
|
|
1411
|
+
else if (entryStat.isFile()) {
|
|
1412
|
+
await copyFilePreservingTimestamps(sourceEntry, destinationEntry, {
|
|
1413
|
+
sourceRoot,
|
|
1414
|
+
destinationRoot,
|
|
1415
|
+
expectedSourceStat: entryStat,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
await assertHistoryDestinationDirectorySafe(destination, destinationRoot);
|
|
1420
|
+
await chmodHistoryDestination(destination, destinationMode ?? sourceDestinationMode, destinationWasExistingDirectory);
|
|
1421
|
+
await assertHistoryDestinationDirectorySafe(destination, destinationRoot);
|
|
1422
|
+
await utimesHistoryDestination(destination, sourceStat.atime, sourceStat.mtime, destinationWasExistingDirectory);
|
|
1423
|
+
}
|
|
1424
|
+
async function replaceProjectLaunchRuntimeHistoryDirectory(source, destination, allowTopLevelSymlink, destinationRoot, expectedSourceStat, afterStage) {
|
|
1425
|
+
const temporary = `${destination}.omx-history-${randomUUID()}`;
|
|
1426
|
+
try {
|
|
1427
|
+
const sourceStat = allowTopLevelSymlink ? await stat(source) : await lstat(source);
|
|
1428
|
+
if (!sourceStat.isDirectory() || (!allowTopLevelSymlink && sourceStat.isSymbolicLink())) {
|
|
1429
|
+
throw new Error(`history source is not a directory: ${source}`);
|
|
1430
|
+
}
|
|
1431
|
+
if (!hasSameHistoryIdentity(sourceStat, expectedSourceStat)) {
|
|
1432
|
+
throw new HistoryEntryChangedError(`history source changed during directory publication: ${source}`);
|
|
1433
|
+
}
|
|
1434
|
+
await copyProjectLaunchRuntimeHistoryDirectory(source, temporary, allowTopLevelSymlink, source, destinationRoot, sourceStat);
|
|
1435
|
+
await afterStage?.(temporary, destination);
|
|
1436
|
+
const stagedStat = await lstat(temporary);
|
|
1437
|
+
if (!stagedStat.isDirectory() || stagedStat.isSymbolicLink()) {
|
|
1438
|
+
throw new Error(`history staging path is not a directory: ${temporary}`);
|
|
755
1439
|
}
|
|
756
|
-
await
|
|
1440
|
+
await assertHistoryDestinationParentWithin(destination, destinationRoot);
|
|
1441
|
+
await publishHistoryReplacement(temporary, destination, true);
|
|
1442
|
+
}
|
|
1443
|
+
finally {
|
|
1444
|
+
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
757
1445
|
}
|
|
758
1446
|
}
|
|
759
1447
|
async function mergeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceCodexHome, mergedHistorySourceRealpaths) {
|
|
1448
|
+
const destinationRoot = await realpath(runtimeCodexHome);
|
|
760
1449
|
for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
|
|
761
1450
|
const source = join(sourceCodexHome, entryName);
|
|
762
|
-
|
|
1451
|
+
const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
|
|
1452
|
+
if (!sourceInspection?.targetStat || !isRegularHistoryTarget(sourceInspection.targetStat))
|
|
1453
|
+
continue;
|
|
1454
|
+
const sourceRealpath = sourceInspection.targetRealpath ??
|
|
1455
|
+
await realpath(source).catch((error) => {
|
|
1456
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1457
|
+
return undefined;
|
|
1458
|
+
throw error;
|
|
1459
|
+
});
|
|
1460
|
+
if (!sourceRealpath)
|
|
763
1461
|
continue;
|
|
764
|
-
const sourceRealpath = realpathSync(source);
|
|
765
1462
|
if (mergedHistorySourceRealpaths.has(sourceRealpath))
|
|
766
1463
|
continue;
|
|
1464
|
+
if (!(await historyEntryStillMatches(source, sourceInspection)))
|
|
1465
|
+
continue;
|
|
767
1466
|
const destination = join(runtimeCodexHome, entryName);
|
|
768
|
-
const sourceStat =
|
|
1467
|
+
const sourceStat = sourceInspection.targetStat;
|
|
1468
|
+
if (!sourceStat)
|
|
1469
|
+
continue;
|
|
769
1470
|
if (sourceStat.isDirectory()) {
|
|
1471
|
+
const destinationStat = await lstat(destination).catch((error) => {
|
|
1472
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1473
|
+
return undefined;
|
|
1474
|
+
throw error;
|
|
1475
|
+
});
|
|
1476
|
+
if (destinationStat && (!destinationStat.isDirectory() || destinationStat.isSymbolicLink())) {
|
|
1477
|
+
await rm(destination, { recursive: true, force: true });
|
|
1478
|
+
}
|
|
770
1479
|
await mkdir(destination, { recursive: true });
|
|
771
|
-
|
|
1480
|
+
try {
|
|
1481
|
+
await copyProjectLaunchRuntimeHistoryDirectory(sourceInspection.targetRealpath ?? source, destination, false, sourceInspection.targetRealpath ?? source, destinationRoot, sourceStat);
|
|
1482
|
+
}
|
|
1483
|
+
catch (error) {
|
|
1484
|
+
if (isDiscardableHistoryCopyError(error))
|
|
1485
|
+
continue;
|
|
1486
|
+
throw error;
|
|
1487
|
+
}
|
|
772
1488
|
mergedHistorySourceRealpaths.add(sourceRealpath);
|
|
773
1489
|
continue;
|
|
774
1490
|
}
|
|
@@ -776,22 +1492,70 @@ async function mergeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, sourceC
|
|
|
776
1492
|
continue;
|
|
777
1493
|
if (!sourceStat.isFile())
|
|
778
1494
|
continue;
|
|
779
|
-
|
|
780
|
-
|
|
1495
|
+
const destinationLinkStat = await lstat(destination).catch((error) => {
|
|
1496
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1497
|
+
return undefined;
|
|
1498
|
+
throw error;
|
|
1499
|
+
});
|
|
1500
|
+
if (destinationLinkStat?.isSymbolicLink()) {
|
|
1501
|
+
await rm(destination, { force: true });
|
|
1502
|
+
}
|
|
1503
|
+
if (destinationLinkStat && !destinationLinkStat.isSymbolicLink()) {
|
|
1504
|
+
const destinationStat = destinationLinkStat;
|
|
781
1505
|
if (!destinationStat.isFile()) {
|
|
782
1506
|
await rm(destination, { recursive: true, force: true });
|
|
783
|
-
|
|
1507
|
+
try {
|
|
1508
|
+
await copyFilePreservingTimestamps(sourceInspection.targetRealpath ?? source, destination, {
|
|
1509
|
+
allowTopLevelSymlink: false,
|
|
1510
|
+
sourceRoot: sourceInspection.targetRealpath ?? source,
|
|
1511
|
+
destinationRoot,
|
|
1512
|
+
expectedSourceStat: sourceStat,
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
catch (error) {
|
|
1516
|
+
if (isDiscardableHistoryCopyError(error))
|
|
1517
|
+
continue;
|
|
1518
|
+
throw error;
|
|
1519
|
+
}
|
|
784
1520
|
mergedHistorySourceRealpaths.add(sourceRealpath);
|
|
785
1521
|
continue;
|
|
786
1522
|
}
|
|
787
|
-
|
|
788
|
-
|
|
1523
|
+
let existing;
|
|
1524
|
+
let addition;
|
|
1525
|
+
try {
|
|
1526
|
+
existing = await readHistoryFileWithoutSymlink(destination, false, destinationRoot);
|
|
1527
|
+
addition = await readHistoryFileWithoutSymlink(sourceInspection.targetRealpath ?? source, false, sourceInspection.targetRealpath ?? source);
|
|
1528
|
+
}
|
|
1529
|
+
catch (error) {
|
|
1530
|
+
if (isDiscardableHistoryCopyError(error))
|
|
1531
|
+
continue;
|
|
1532
|
+
throw error;
|
|
1533
|
+
}
|
|
789
1534
|
const separator = existing === "" || existing.endsWith("\n") || addition === "" ? "" : "\n";
|
|
790
|
-
|
|
1535
|
+
try {
|
|
1536
|
+
await writeHistoryFileAtomically(destination, `${existing}${separator}${addition}`, destinationStat.mode & 0o7777, destinationRoot);
|
|
1537
|
+
}
|
|
1538
|
+
catch (error) {
|
|
1539
|
+
if (isDiscardableHistoryCopyError(error))
|
|
1540
|
+
continue;
|
|
1541
|
+
throw error;
|
|
1542
|
+
}
|
|
791
1543
|
mergedHistorySourceRealpaths.add(sourceRealpath);
|
|
792
1544
|
continue;
|
|
793
1545
|
}
|
|
794
|
-
|
|
1546
|
+
try {
|
|
1547
|
+
await copyFilePreservingTimestamps(sourceInspection.targetRealpath ?? source, destination, {
|
|
1548
|
+
allowTopLevelSymlink: false,
|
|
1549
|
+
sourceRoot: sourceInspection.targetRealpath ?? source,
|
|
1550
|
+
destinationRoot,
|
|
1551
|
+
expectedSourceStat: sourceStat,
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
catch (error) {
|
|
1555
|
+
if (isDiscardableHistoryCopyError(error))
|
|
1556
|
+
continue;
|
|
1557
|
+
throw error;
|
|
1558
|
+
}
|
|
795
1559
|
mergedHistorySourceRealpaths.add(sourceRealpath);
|
|
796
1560
|
}
|
|
797
1561
|
}
|
|
@@ -812,7 +1576,7 @@ export async function prepareRuntimeCodexHomeForProjectLaunch(cwd, sessionId, pr
|
|
|
812
1576
|
await rm(runtimeCodexHome, { recursive: true, force: true });
|
|
813
1577
|
await mkdir(runtimeCodexHome, { recursive: true });
|
|
814
1578
|
if (!existsSync(projectCodexHome)) {
|
|
815
|
-
await ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome);
|
|
1579
|
+
await ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome, new Set(), options.createSymlink);
|
|
816
1580
|
return runtimeCodexHome;
|
|
817
1581
|
}
|
|
818
1582
|
for (const entry of await readdir(projectCodexHome, { withFileTypes: true })) {
|
|
@@ -822,6 +1586,16 @@ export async function prepareRuntimeCodexHomeForProjectLaunch(cwd, sessionId, pr
|
|
|
822
1586
|
continue;
|
|
823
1587
|
const source = join(projectCodexHome, entry.name);
|
|
824
1588
|
const destination = join(runtimeCodexHome, entry.name);
|
|
1589
|
+
if (PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES.has(entry.name)) {
|
|
1590
|
+
const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
|
|
1591
|
+
if (!sourceInspection?.targetStat)
|
|
1592
|
+
continue;
|
|
1593
|
+
if (options.includeHistoryArtifacts === true) {
|
|
1594
|
+
if (!isRegularHistoryTarget(sourceInspection.targetStat))
|
|
1595
|
+
continue;
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
825
1599
|
if (entry.name === "config.toml") {
|
|
826
1600
|
const projectHooksPath = join(projectCodexHome, "hooks.json");
|
|
827
1601
|
const projectConfig = await readFile(source, "utf-8");
|
|
@@ -838,19 +1612,37 @@ export async function prepareRuntimeCodexHomeForProjectLaunch(cwd, sessionId, pr
|
|
|
838
1612
|
}
|
|
839
1613
|
await linkOrCopyCodexHomeEntry(source, destination);
|
|
840
1614
|
}
|
|
841
|
-
|
|
842
|
-
if (options.includeHistoryArtifacts === true
|
|
1615
|
+
let skippedHistoryEntryNames = new Set();
|
|
1616
|
+
if (options.includeHistoryArtifacts === true) {
|
|
1617
|
+
const extraHistoryCodexHomes = options.extraHistoryCodexHomes ?? [];
|
|
1618
|
+
const mergingHistory = extraHistoryCodexHomes.length > 0;
|
|
843
1619
|
const mergedHistorySourceRealpaths = new Set();
|
|
844
1620
|
for (const entryName of PROJECT_LAUNCH_DURABLE_HISTORY_ENTRY_NAMES) {
|
|
845
1621
|
const source = join(projectCodexHome, entryName);
|
|
846
|
-
|
|
847
|
-
|
|
1622
|
+
const sourceInspection = await inspectProjectLaunchRuntimeHistoryEntry(source);
|
|
1623
|
+
if (!sourceInspection?.targetStat || !isRegularHistoryTarget(sourceInspection.targetStat))
|
|
1624
|
+
continue;
|
|
1625
|
+
const sourceRealpath = sourceInspection.targetRealpath ??
|
|
1626
|
+
await realpath(source).catch((error) => {
|
|
1627
|
+
if (isUnresolvableHistoryLinkError(error))
|
|
1628
|
+
return undefined;
|
|
1629
|
+
throw error;
|
|
1630
|
+
});
|
|
1631
|
+
if (sourceRealpath)
|
|
1632
|
+
mergedHistorySourceRealpaths.add(sourceRealpath);
|
|
848
1633
|
}
|
|
849
|
-
await materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, projectCodexHome
|
|
850
|
-
|
|
1634
|
+
skippedHistoryEntryNames = await materializeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, projectCodexHome, {
|
|
1635
|
+
onlySymlinks: !mergingHistory,
|
|
1636
|
+
beforeCopy: options.beforeHistoryEntryCopy,
|
|
1637
|
+
afterValidation: options.afterHistoryEntryValidation,
|
|
1638
|
+
afterStage: options.afterHistoryEntryStage,
|
|
1639
|
+
afterSourceOpen: options.afterHistorySourceOpen,
|
|
1640
|
+
});
|
|
1641
|
+
for (const extraCodexHome of extraHistoryCodexHomes) {
|
|
851
1642
|
await mergeProjectLaunchRuntimeHistoryEntries(runtimeCodexHome, extraCodexHome, mergedHistorySourceRealpaths);
|
|
852
1643
|
}
|
|
853
1644
|
}
|
|
1645
|
+
await ensureProjectLaunchRuntimeHistoryLinks(runtimeCodexHome, projectCodexHome, skippedHistoryEntryNames, options.createSymlink);
|
|
854
1646
|
return runtimeCodexHome;
|
|
855
1647
|
}
|
|
856
1648
|
function resolveProjectSqliteHomeForLaunch(projectCodexHome, env) {
|
|
@@ -1013,6 +1805,8 @@ async function prepareResumeCodexHomeForLaunch(cwd, sessionId, args, env = proce
|
|
|
1013
1805
|
args: selection.args,
|
|
1014
1806
|
prepared: {
|
|
1015
1807
|
codexHomeOverride: runtimeCodexHome,
|
|
1808
|
+
projectLocalCodexHomeForCleanup: projectHomes[0].path,
|
|
1809
|
+
runtimeCodexHomeForCleanup: runtimeCodexHome,
|
|
1016
1810
|
},
|
|
1017
1811
|
};
|
|
1018
1812
|
}
|
|
@@ -1044,9 +1838,36 @@ export async function persistProjectLaunchRuntimeProjectTrustState(runtimeCodexH
|
|
|
1044
1838
|
export async function cleanupRuntimeCodexHome(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence) {
|
|
1045
1839
|
if (!runtimeCodexHomeForCleanup)
|
|
1046
1840
|
return;
|
|
1841
|
+
const runtimeParent = dirname(runtimeCodexHomeForCleanup);
|
|
1842
|
+
const initialParentStat = await lstat(runtimeParent).catch(() => undefined);
|
|
1843
|
+
const initialParentRealpath = await realpath(runtimeParent).catch(() => undefined);
|
|
1844
|
+
const initialRuntimeStat = await lstat(runtimeCodexHomeForCleanup).catch(() => undefined);
|
|
1845
|
+
if (!initialParentStat || !initialParentRealpath || !initialRuntimeStat)
|
|
1846
|
+
return;
|
|
1847
|
+
if (initialParentStat.isSymbolicLink())
|
|
1848
|
+
return;
|
|
1849
|
+
try {
|
|
1850
|
+
await assertHistoryPathWithin(runtimeCodexHomeForCleanup, initialParentRealpath);
|
|
1851
|
+
}
|
|
1852
|
+
catch {
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1047
1855
|
await persistProjectLaunchRuntimeAuthState(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence);
|
|
1048
|
-
await persistProjectLaunchRuntimeHistoryArtifacts(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence);
|
|
1856
|
+
const historyPersisted = await persistProjectLaunchRuntimeHistoryArtifacts(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence, { dev: initialRuntimeStat.dev, ino: initialRuntimeStat.ino });
|
|
1857
|
+
if (!historyPersisted)
|
|
1858
|
+
return;
|
|
1049
1859
|
await persistProjectLaunchRuntimeProjectTrustState(runtimeCodexHomeForCleanup, projectCodexHomeForPersistence);
|
|
1860
|
+
const finalParentStat = await lstat(runtimeParent).catch(() => undefined);
|
|
1861
|
+
const finalParentRealpath = await realpath(runtimeParent).catch(() => undefined);
|
|
1862
|
+
const finalRuntimeStat = await lstat(runtimeCodexHomeForCleanup).catch(() => undefined);
|
|
1863
|
+
if (!finalParentStat ||
|
|
1864
|
+
finalParentStat.isSymbolicLink() ||
|
|
1865
|
+
!finalParentRealpath ||
|
|
1866
|
+
finalParentRealpath !== initialParentRealpath ||
|
|
1867
|
+
!hasSameHistoryIdentity(finalParentStat, initialParentStat) ||
|
|
1868
|
+
!finalRuntimeStat ||
|
|
1869
|
+
!hasSameHistoryIdentity(finalRuntimeStat, initialRuntimeStat))
|
|
1870
|
+
return;
|
|
1050
1871
|
await rm(runtimeCodexHomeForCleanup, { recursive: true, force: true });
|
|
1051
1872
|
}
|
|
1052
1873
|
function execTmuxFileSync(args, options) {
|
|
@@ -1091,6 +1912,27 @@ export function parseDetachedLeaderPaneIdByPid(snapshot, leaderPid) {
|
|
|
1091
1912
|
throw new Error("detached leader pane identity is unavailable");
|
|
1092
1913
|
return matches[0];
|
|
1093
1914
|
}
|
|
1915
|
+
function resolveDetachedLeaderPaneIdentity(sessionName, expectedTmuxSessionId, inheritedPane, platform, leaderPid, requireInheritedPane, probeInheritedPane, listPanes) {
|
|
1916
|
+
if (inheritedPane && /^%[0-9]+$/.test(inheritedPane)) {
|
|
1917
|
+
try {
|
|
1918
|
+
const [observedSessionName, observedSessionId, observedPaneId] = probeInheritedPane(inheritedPane).split("\t");
|
|
1919
|
+
if (observedSessionName === sessionName && observedPaneId === inheritedPane
|
|
1920
|
+
&& (expectedTmuxSessionId === undefined || observedSessionId === expectedTmuxSessionId))
|
|
1921
|
+
return inheritedPane;
|
|
1922
|
+
}
|
|
1923
|
+
catch {
|
|
1924
|
+
// Fall through to the platform-specific resolution policy below.
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
if (requireInheritedPane || platform === "win32") {
|
|
1928
|
+
throw new Error("detached leader inherited pane identity is unavailable");
|
|
1929
|
+
}
|
|
1930
|
+
return parseDetachedLeaderPaneIdByPid(listPanes(), leaderPid);
|
|
1931
|
+
}
|
|
1932
|
+
/** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
|
|
1933
|
+
export function resolveDetachedLeaderPaneForTest(options) {
|
|
1934
|
+
return resolveDetachedLeaderPaneIdentity(options.sessionName, options.expectedTmuxSessionId, options.inheritedPane, options.platform, options.leaderPid, options.requireInheritedPane, () => options.identityOutput, () => options.paneSnapshot);
|
|
1935
|
+
}
|
|
1094
1936
|
function detachedLeaderAuthorityCondition(authority, requireOwner = true) {
|
|
1095
1937
|
const conditions = [
|
|
1096
1938
|
"#{==:#{pane_dead},0}",
|
|
@@ -1104,14 +1946,17 @@ function detachedLeaderAuthorityCondition(authority, requireOwner = true) {
|
|
|
1104
1946
|
];
|
|
1105
1947
|
return conditions.reduce((combined, condition) => `#{&&:${combined},${condition}}`);
|
|
1106
1948
|
}
|
|
1107
|
-
function detachedHudAuthorityCondition(authority, requireMarker = true) {
|
|
1949
|
+
function detachedHudAuthorityCondition(authority, requireMarker = true, ownerId) {
|
|
1108
1950
|
const conditions = [
|
|
1109
1951
|
"#{==:#{pane_dead},0}",
|
|
1110
1952
|
`#{==:#{pane_id},${authority.paneId}}`,
|
|
1111
1953
|
`#{==:#{pane_pid},${authority.panePid}}`,
|
|
1954
|
+
`#{==:#{session_name},${authority.sessionName}}`,
|
|
1112
1955
|
`#{==:#{session_id},${authority.sessionId}}`,
|
|
1956
|
+
`#{==:#{session_created},${authority.sessionCreated}}`,
|
|
1113
1957
|
`#{==:#{window_id},${authority.windowId}}`,
|
|
1114
1958
|
...(requireMarker ? [`#{m:*OMX_DETACHED_HUD_OPERATION=${authority.operationMarker}*,#{pane_start_command}}`] : []),
|
|
1959
|
+
...(ownerId ? [`#{==:#{@omx_hud_owner},${ownerId}}`] : []),
|
|
1115
1960
|
];
|
|
1116
1961
|
return conditions.reduce((combined, condition) => `#{&&:${combined},${condition}}`);
|
|
1117
1962
|
}
|
|
@@ -1146,26 +1991,167 @@ function runDetachedLeaderMutation(authority, args, requireOwner = true, retryMi
|
|
|
1146
1991
|
const mutation = args.length >= 4 && args[0] === "set-option" ? `${args[0]} ${args.at(-2)}` : args[0] ?? "unknown";
|
|
1147
1992
|
throw new Error(`detached leader authority blocked tmux mutation ${mutation}: ${describeDetachedLeaderAuthorityMismatch(authority)}`);
|
|
1148
1993
|
}
|
|
1149
|
-
function
|
|
1994
|
+
function runDetachedLeaderTagMutation(authority, ownerId) {
|
|
1995
|
+
const receipt = detachedAuthorityReceipt();
|
|
1996
|
+
const phaseMarker = ["set-option", "-t", authority.sessionName, "@omx_detached_owner_tag_attempted", "1"].map(quoteShellArg).join(" ");
|
|
1997
|
+
const ownerTag = ["set-option", "-t", authority.sessionName, OMX_INSTANCE_OPTION, ownerId].map(quoteShellArg).join(" ");
|
|
1998
|
+
const success = `${phaseMarker} ; ${ownerTag} ; display-message -p ${quoteShellArg(receipt)}`;
|
|
1999
|
+
const output = execTmuxFileSync([
|
|
2000
|
+
"if-shell", "-F", "-t", authority.paneId, detachedLeaderAuthorityCondition(authority, false),
|
|
2001
|
+
success, "display-message -p ''",
|
|
2002
|
+
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2003
|
+
if (output !== receipt)
|
|
2004
|
+
throw new Error(`detached leader authority blocked tmux mutation tag-session: ${describeDetachedLeaderAuthorityMismatch(authority)}`);
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* #3578: session-scoped fence for pre-report cleanup when the leader pane no
|
|
2008
|
+
* longer exists at all. A normal leader exit with `remain-on-exit off` (set by
|
|
2009
|
+
* the tag-session step) removes the pane, so the pane-dead predicate above can
|
|
2010
|
+
* never match and rollback leaked a HUD-only session. The replacement fence is
|
|
2011
|
+
* evaluated against the SESSION (a server-scoped stable identity), not a pane:
|
|
2012
|
+
* exact session_name + session_id + session_created, plus either this launch's
|
|
2013
|
+
* @omx_instance_id owner tag or the empty owner state that exists before the
|
|
2014
|
+
* tag-session step succeeds. A foreign tag is never accepted. The leader-pane
|
|
2015
|
+
* probe is retained as a fail-closed existence check, but is not reused as a
|
|
2016
|
+
* pane-targeted destructive sink; a replacement session has a different
|
|
2017
|
+
* session_id/session_created and fails the session fence.
|
|
2018
|
+
*/
|
|
2019
|
+
function detachedPreReportSessionCleanupCondition(authority, allowUnownedPreTag, requireCapturedDeadPane) {
|
|
2020
|
+
// The tag-session step is the first mutation after new-session. A bootstrap
|
|
2021
|
+
// failure can therefore leave a retained dead leader pane in a session whose
|
|
2022
|
+
// exact identity is ours but whose owner option is still unset. When the
|
|
2023
|
+
// caller has authenticated the pre-tag phase, accept only that empty state
|
|
2024
|
+
// or our expected tag; otherwise require our expected tag. A foreign tag
|
|
2025
|
+
// remains a hard denial. Session id + creation time prevent a name-reused
|
|
2026
|
+
// session from satisfying the pre-tag branch.
|
|
2027
|
+
const owner = allowUnownedPreTag
|
|
2028
|
+
? `#{||:#{==:#{@omx_instance_id},${authority.ownerId}},#{==:#{@omx_instance_id},}}`
|
|
2029
|
+
: `#{==:#{@omx_instance_id},${authority.ownerId}}`;
|
|
1150
2030
|
const conditions = [
|
|
1151
|
-
"#{==:#{pane_dead},1}",
|
|
1152
|
-
`#{==:#{pane_id},${authority.paneId}}`,
|
|
1153
2031
|
`#{==:#{session_name},${authority.sessionName}}`,
|
|
1154
2032
|
`#{==:#{session_id},${authority.sessionId}}`,
|
|
1155
2033
|
`#{==:#{session_created},${authority.sessionCreated}}`,
|
|
1156
|
-
|
|
2034
|
+
owner,
|
|
1157
2035
|
];
|
|
2036
|
+
if (requireCapturedDeadPane) {
|
|
2037
|
+
// The session target resolves the active pane without using a pane target.
|
|
2038
|
+
// If the captured dead pane was respawned, its PID or liveness changes and
|
|
2039
|
+
// this single queued tmux predicate refuses the destructive sink. If a
|
|
2040
|
+
// different pane is active, preserving is the only safe result.
|
|
2041
|
+
conditions.push(`#{==:#{pane_id},${authority.paneId}}`, `#{==:#{pane_pid},${authority.panePid}}`, "#{==:#{pane_dead},1}");
|
|
2042
|
+
}
|
|
1158
2043
|
return conditions.reduce((combined, condition) => `#{&&:${combined},${condition}}`);
|
|
1159
2044
|
}
|
|
1160
|
-
|
|
2045
|
+
function detachedPreReportLeaderPaneState(authority) {
|
|
2046
|
+
// Enumerate the exact named session and classify the captured pane before any
|
|
2047
|
+
// destructive command. A live captured leader is always preserved: a report
|
|
2048
|
+
// can arrive after the final file read and before a separate tmux command,
|
|
2049
|
+
// so timeout-based cleanup must never kill a pane that is still running.
|
|
2050
|
+
// Enumeration failures (notably the exact session no longer exists) are
|
|
2051
|
+
// fail-closed: unknown topology preserves without mutation.
|
|
2052
|
+
try {
|
|
2053
|
+
const sessionTarget = authority.sessionName;
|
|
2054
|
+
const output = execTmuxFileSync(["list-panes", "-s", "-t", sessionTarget, "-F", "#{pane_id}\t#{pane_dead}"], {
|
|
2055
|
+
encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
2056
|
+
});
|
|
2057
|
+
const row = output.split("\n").map((line) => line.trim()).filter(Boolean).find((line) => line.split("\t")[0] === authority.paneId);
|
|
2058
|
+
if (!row)
|
|
2059
|
+
return "absent";
|
|
2060
|
+
const paneDead = row.split("\t")[1];
|
|
2061
|
+
if (paneDead === "1")
|
|
2062
|
+
return "dead";
|
|
2063
|
+
if (paneDead === "0")
|
|
2064
|
+
return "live";
|
|
2065
|
+
return "unknown";
|
|
2066
|
+
}
|
|
2067
|
+
catch {
|
|
2068
|
+
return "unknown";
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
function detachedSessionExists(sessionName) {
|
|
2072
|
+
try {
|
|
2073
|
+
execTmuxFileSync(["has-session", "-t", sessionName], {
|
|
2074
|
+
encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
2075
|
+
});
|
|
2076
|
+
return true;
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
return false;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
function cleanupDetachedPreReportSessionInternal(authority, afterTopologyProbe, allowUnownedPreTag = false, authenticatedReadyOrTerminalProbe, afterAuthenticatedReadyOrTerminalProbe) {
|
|
1161
2083
|
const receipt = detachedAuthorityReceipt();
|
|
1162
|
-
const
|
|
1163
|
-
const
|
|
1164
|
-
|
|
2084
|
+
const sessionTarget = authority.sessionName;
|
|
2085
|
+
const success = `kill-session -t ${quoteShellArg(sessionTarget)} ; display-message -p ${quoteShellArg(receipt)}`;
|
|
2086
|
+
// #3562/#3578: probe the exact session topology for diagnostics, but never
|
|
2087
|
+
// feed the captured pane id into the destructive sink. The probe is
|
|
2088
|
+
// non-atomic; a leader pane can disappear or be reused before the sink runs.
|
|
2089
|
+
// The sink therefore targets only the session name and rechecks the complete
|
|
2090
|
+
// session identity plus the authenticated owner/pre-tag fence in the same
|
|
2091
|
+
// tmux command queue.
|
|
2092
|
+
const paneState = detachedPreReportLeaderPaneState(authority);
|
|
2093
|
+
if (paneState === "unknown")
|
|
2094
|
+
throw new Error("detached pre-report topology changed before cleanup");
|
|
2095
|
+
afterTopologyProbe?.();
|
|
2096
|
+
const authenticatedReady = authenticatedReadyOrTerminalProbe?.() === true;
|
|
2097
|
+
afterAuthenticatedReadyOrTerminalProbe?.();
|
|
2098
|
+
if (authenticatedReady) {
|
|
2099
|
+
throw new Error("detached leader became ready or terminal before pre-report cleanup");
|
|
2100
|
+
}
|
|
2101
|
+
if (paneState === "live")
|
|
2102
|
+
return "preserved-live";
|
|
2103
|
+
const sessionScoped = execTmuxFileSync([
|
|
2104
|
+
"if-shell", "-F", "-t", sessionTarget, detachedPreReportSessionCleanupCondition(authority, allowUnownedPreTag, paneState === "dead"),
|
|
1165
2105
|
success, "display-message -p ''",
|
|
1166
2106
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1167
|
-
if (
|
|
2107
|
+
if (sessionScoped !== receipt)
|
|
1168
2108
|
throw new Error("detached pre-report topology changed before cleanup");
|
|
2109
|
+
return "cleaned";
|
|
2110
|
+
}
|
|
2111
|
+
function cleanupDetachedPreReportSessionWithRetry(authority, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe, retryAuthenticatedFailure = true) {
|
|
2112
|
+
let result;
|
|
2113
|
+
try {
|
|
2114
|
+
result = cleanupDetachedPreReportSessionInternal(authority, undefined, allowUnownedPreTag, authenticatedReadyOrTerminalProbe);
|
|
2115
|
+
}
|
|
2116
|
+
catch (error) {
|
|
2117
|
+
// A failed leader may have already removed its own exact session before
|
|
2118
|
+
// the parent reaches rollback. Only an authenticated failed report permits
|
|
2119
|
+
// acknowledging that disappearance as completed cleanup.
|
|
2120
|
+
if (authenticatedFailedReportProbe?.() && !detachedSessionExists(authority.sessionName))
|
|
2121
|
+
return "cleaned";
|
|
2122
|
+
throw error;
|
|
2123
|
+
}
|
|
2124
|
+
if (result === "cleaned" || !retryAuthenticatedFailure || !authenticatedFailedReportProbe?.())
|
|
2125
|
+
return result;
|
|
2126
|
+
// A failed report may be published while the leader pane is still live. Do
|
|
2127
|
+
// not treat that observation as successful cleanup: retain the marker and
|
|
2128
|
+
// retry the identity-fenced decision after the leader exits. A later ready,
|
|
2129
|
+
// terminal, respawn, or ownership change fails closed inside the retry.
|
|
2130
|
+
const deadline = Date.now() + 30_000;
|
|
2131
|
+
while (Date.now() < deadline) {
|
|
2132
|
+
blockMs(20);
|
|
2133
|
+
try {
|
|
2134
|
+
result = cleanupDetachedPreReportSessionInternal(authority, undefined, allowUnownedPreTag, authenticatedReadyOrTerminalProbe);
|
|
2135
|
+
}
|
|
2136
|
+
catch (error) {
|
|
2137
|
+
if (authenticatedFailedReportProbe() && !detachedSessionExists(authority.sessionName))
|
|
2138
|
+
return "cleaned";
|
|
2139
|
+
throw error;
|
|
2140
|
+
}
|
|
2141
|
+
if (result === "cleaned")
|
|
2142
|
+
return result;
|
|
2143
|
+
}
|
|
2144
|
+
throw new Error("detached leader remained live before pre-report cleanup retry");
|
|
2145
|
+
}
|
|
2146
|
+
export function cleanupDetachedPreReportSession(authority, allowUnownedPreTag = false, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe, retryAuthenticatedFailure = true) {
|
|
2147
|
+
return cleanupDetachedPreReportSessionWithRetry(authority, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe, retryAuthenticatedFailure);
|
|
2148
|
+
}
|
|
2149
|
+
/** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
|
|
2150
|
+
export function cleanupDetachedPreReportSessionForTest(authority, afterTopologyProbe, allowUnownedPreTag = false, authenticatedReadyOrTerminalProbe, afterAuthenticatedReadyOrTerminalProbe, retryAfterLive = false, authenticatedFailedReportProbe) {
|
|
2151
|
+
const result = cleanupDetachedPreReportSessionInternal(authority, afterTopologyProbe, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, afterAuthenticatedReadyOrTerminalProbe);
|
|
2152
|
+
if (retryAfterLive && result === "preserved-live") {
|
|
2153
|
+
cleanupDetachedPreReportSessionWithRetry(authority, allowUnownedPreTag, authenticatedReadyOrTerminalProbe, authenticatedFailedReportProbe);
|
|
2154
|
+
}
|
|
1169
2155
|
}
|
|
1170
2156
|
function runDetachedHudMutation(leaderAuthority, hudAuthority, args, requireMarker = true) {
|
|
1171
2157
|
const receipt = detachedAuthorityReceipt();
|
|
@@ -1178,47 +2164,151 @@ function runDetachedHudMutation(leaderAuthority, hudAuthority, args, requireMark
|
|
|
1178
2164
|
if (output !== receipt)
|
|
1179
2165
|
throw new Error("detached leader or HUD authority changed before tmux mutation");
|
|
1180
2166
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
2167
|
+
function detachedHudPaneProofFormat() {
|
|
2168
|
+
// The trailing literal sentinel keeps an empty @omx_hud_owner from being
|
|
2169
|
+
// trimmed away as trailing whitespace by tmux's format output.
|
|
2170
|
+
return "#{pane_id}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}\t#{window_id}\t#{pane_dead}\t#{@omx_hud_owner}\t#{pane_start_command}\tEND";
|
|
2171
|
+
}
|
|
2172
|
+
function parseDetachedHudPaneProofLine(line) {
|
|
2173
|
+
const fields = line.split("\t");
|
|
2174
|
+
// Seven fixed fields, then @omx_hud_owner, then pane_start_command (which may
|
|
2175
|
+
// itself contain TAB bytes), then the literal END sentinel.
|
|
2176
|
+
if (fields.length < 10 || fields.at(-1) !== "END")
|
|
2177
|
+
return undefined;
|
|
2178
|
+
const [paneId, panePid, sessionName, sessionId, sessionCreated, windowId, paneDead, hudOwner] = fields;
|
|
2179
|
+
const paneStartCommand = fields.slice(8, -1).join("\t");
|
|
2180
|
+
return {
|
|
2181
|
+
paneId: paneId ?? "", panePid: panePid ?? "", sessionName: sessionName ?? "",
|
|
2182
|
+
sessionId: sessionId ?? "", sessionCreated: sessionCreated ?? "", windowId: windowId ?? "",
|
|
2183
|
+
paneDead: paneDead ?? "", paneStartCommand, hudOwner: hudOwner ?? "",
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
function matchesDetachedHudAuthority(proof, authority, ownerId) {
|
|
2187
|
+
if (proof.paneId !== authority.paneId)
|
|
2188
|
+
return false;
|
|
2189
|
+
if (Number(proof.panePid) !== authority.panePid)
|
|
2190
|
+
return false;
|
|
2191
|
+
if (proof.paneDead !== "0")
|
|
2192
|
+
return false;
|
|
2193
|
+
if (proof.sessionName !== authority.sessionName)
|
|
2194
|
+
return false;
|
|
2195
|
+
if (proof.sessionId !== authority.sessionId)
|
|
2196
|
+
return false;
|
|
2197
|
+
if (proof.sessionCreated !== authority.sessionCreated)
|
|
2198
|
+
return false;
|
|
2199
|
+
if (proof.windowId !== authority.windowId)
|
|
2200
|
+
return false;
|
|
2201
|
+
// Marker non-vacuity: tmux reports pane_start_command wrapped in double
|
|
2202
|
+
// quotes; the command must begin with the exact env assignment the
|
|
2203
|
+
// production split sink injects. A pane whose command merely contains the
|
|
2204
|
+
// marker text (e.g. an echo) is not the HUD.
|
|
2205
|
+
const markerPrefix = `env OMX_DETACHED_HUD_OPERATION=${authority.operationMarker} `;
|
|
2206
|
+
const startCommand = proof.paneStartCommand.replace(/^"|"$/g, "");
|
|
2207
|
+
if (!startCommand.startsWith(markerPrefix))
|
|
2208
|
+
return false;
|
|
2209
|
+
if (ownerId !== undefined && proof.hudOwner !== ownerId)
|
|
2210
|
+
return false;
|
|
2211
|
+
return true;
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Enumerate the exact expected HUD pane inside its proven session scope and
|
|
2215
|
+
* evaluate the full proof locally. The tmux enumeration target is the session
|
|
2216
|
+
* name (a server-scoped stable identity), never the pane id: a stale or
|
|
2217
|
+
* missing pane proof must resolve to an ordinary fail-closed preserve result
|
|
2218
|
+
* instead of an `if-shell -t <stale pane>` evaluation that can tear down the
|
|
2219
|
+
* server with the leader and any replacement/foreign panes (#3562).
|
|
2220
|
+
* `-s` widens the enumeration to every window of the session: an attached user
|
|
2221
|
+
* may select another window, and a window-scoped listing would hide a fully
|
|
2222
|
+
* matching HUD in the original window (#3562 review).
|
|
2223
|
+
*
|
|
2224
|
+
* Preserve (undefined) on malformed enumeration output and any tmux command
|
|
2225
|
+
* failure; callers preserve on zero or multiple authority matches.
|
|
2226
|
+
*/
|
|
2227
|
+
function listDetachedHudPaneProofsInSession(authority) {
|
|
2228
|
+
try {
|
|
2229
|
+
const output = execTmuxFileSync([
|
|
2230
|
+
"list-panes", "-s", "-t", authority.sessionName, "-F", detachedHudPaneProofFormat(),
|
|
1189
2231
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
2232
|
+
const proofs = [];
|
|
2233
|
+
for (const line of output.split("\n")) {
|
|
2234
|
+
const trimmed = line.trim();
|
|
2235
|
+
if (!trimmed)
|
|
2236
|
+
continue;
|
|
2237
|
+
const proof = parseDetachedHudPaneProofLine(trimmed);
|
|
2238
|
+
if (!proof)
|
|
2239
|
+
return undefined;
|
|
2240
|
+
proofs.push(proof);
|
|
2241
|
+
}
|
|
2242
|
+
return proofs;
|
|
2243
|
+
}
|
|
2244
|
+
catch {
|
|
2245
|
+
return undefined;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
function removeDetachedHudPaneIfAuthorized(authority, ownerId) {
|
|
2249
|
+
const proofs = listDetachedHudPaneProofsInSession(authority);
|
|
2250
|
+
if (!proofs)
|
|
2251
|
+
return false;
|
|
2252
|
+
const matching = proofs.filter((proof) => matchesDetachedHudAuthority(proof, authority, ownerId));
|
|
2253
|
+
if (matching.length !== 1)
|
|
2254
|
+
return false;
|
|
2255
|
+
const receipt = detachedAuthorityReceipt();
|
|
2256
|
+
const killed = `killed:${receipt}`;
|
|
2257
|
+
const mismatched = `mismatch:${receipt}`;
|
|
2258
|
+
try {
|
|
2259
|
+
const output = execTmuxFileSync([
|
|
2260
|
+
"if-shell", "-F", "-t", matching[0].paneId,
|
|
2261
|
+
detachedHudAuthorityCondition(authority, true, ownerId),
|
|
2262
|
+
`kill-pane -t ${quoteShellArg(matching[0].paneId)} ; display-message -p ${quoteShellArg(killed)}`,
|
|
2263
|
+
`display-message -p ${quoteShellArg(mismatched)}`,
|
|
2264
|
+
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2265
|
+
return output === killed;
|
|
2266
|
+
}
|
|
2267
|
+
catch {
|
|
2268
|
+
return false;
|
|
1190
2269
|
}
|
|
2270
|
+
}
|
|
2271
|
+
function awaitDetachedHudPaneRemoval(paneId) {
|
|
1191
2272
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
1192
2273
|
const panes = execTmuxFileSync(["list-panes", "-a", "-F", "#{pane_id}"], {
|
|
1193
2274
|
encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
1194
2275
|
}).split("\n").map((value) => value.trim()).filter(Boolean);
|
|
1195
|
-
if (!panes.includes(
|
|
2276
|
+
if (!panes.includes(paneId))
|
|
1196
2277
|
return;
|
|
1197
2278
|
blockMs(20);
|
|
1198
2279
|
}
|
|
1199
2280
|
throw new Error("detached HUD topology changed before teardown");
|
|
1200
2281
|
}
|
|
2282
|
+
export function cleanupDetachedHudPane(authority, ownerId) {
|
|
2283
|
+
if (!removeDetachedHudPaneIfAuthorized(authority, ownerId)) {
|
|
2284
|
+
// A stale, missing, or changed proof is an ordinary fail-closed preserve
|
|
2285
|
+
// result. Throwing here would let callers escalate a benign mismatch into
|
|
2286
|
+
// broader cleanup; the HUD pane and its session are left untouched.
|
|
2287
|
+
return false;
|
|
2288
|
+
}
|
|
2289
|
+
awaitDetachedHudPaneRemoval(authority.paneId);
|
|
2290
|
+
return true;
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Remove the bootstrap HUD after an authenticated finalized leader result.
|
|
2294
|
+
*
|
|
2295
|
+
* The outer launcher retained the original HUD proof while creating the pane.
|
|
2296
|
+
* Revalidate that proof and its owner tag at the tmux mutation sink so failure
|
|
2297
|
+
* cleanup stays fail-closed when a pane is replaced or taken over.
|
|
2298
|
+
*/
|
|
2299
|
+
export function cleanupFinalizedDetachedHud(expected, ownerId) {
|
|
2300
|
+
if (!expected)
|
|
2301
|
+
return false;
|
|
2302
|
+
if (!removeDetachedHudPaneIfAuthorized(expected, ownerId))
|
|
2303
|
+
return false;
|
|
2304
|
+
awaitDetachedHudPaneRemoval(expected.paneId);
|
|
2305
|
+
return true;
|
|
2306
|
+
}
|
|
1201
2307
|
function tagDetachedHudPane(leaderAuthority, authority, ownerId) {
|
|
1202
2308
|
runDetachedHudMutation(leaderAuthority, authority, [
|
|
1203
2309
|
"set-option", "-pq", "-t", authority.paneId, "@omx_hud_owner", ownerId,
|
|
1204
2310
|
], false);
|
|
1205
2311
|
}
|
|
1206
|
-
export function discoverDetachedHudAuthority(sessionName, ownerId) {
|
|
1207
|
-
const rows = execTmuxFileSync([
|
|
1208
|
-
"list-panes", "-a", "-F",
|
|
1209
|
-
"#{pane_id}\t#{pane_dead}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}\t#{window_id}\t#{@omx_hud_owner}",
|
|
1210
|
-
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1211
|
-
const matches = rows.split("\n").map((line) => line.trim()).filter(Boolean).flatMap((line) => {
|
|
1212
|
-
const [paneId, paneDead, panePidRaw, discoveredSessionName, sessionId, sessionCreated, windowId, owner] = line.split("\t");
|
|
1213
|
-
const panePid = Number(panePidRaw);
|
|
1214
|
-
if (paneDead !== "0" || discoveredSessionName !== sessionName || owner !== ownerId || !/^%\d+$/.test(paneId ?? "") || !/^\$\d+$/.test(sessionId ?? "") || !/^\d+$/.test(sessionCreated ?? "") || !/^@\d+$/.test(windowId ?? "") || !Number.isSafeInteger(panePid) || panePid <= 0)
|
|
1215
|
-
return [];
|
|
1216
|
-
return [{ paneId: paneId, panePid, sessionId: sessionId, sessionCreated: sessionCreated, windowId: windowId, operationMarker: randomUUID() }];
|
|
1217
|
-
});
|
|
1218
|
-
if (matches.length > 1)
|
|
1219
|
-
throw new Error("multiple detached HUD ownership tags found");
|
|
1220
|
-
return matches[0];
|
|
1221
|
-
}
|
|
1222
2312
|
function buildDeferredDetachedHudGuard(leaderAuthority, hudAuthority, command) {
|
|
1223
2313
|
const deferredReceipt = detachedAuthorityReceipt();
|
|
1224
2314
|
let guardedSinkCount = 0;
|
|
@@ -1281,20 +2371,20 @@ function runDetachedLeaderSplit(authority, args) {
|
|
|
1281
2371
|
throw new Error("invalid detached HUD split receipt format");
|
|
1282
2372
|
const commandIndex = splitArgs.length - 1;
|
|
1283
2373
|
splitArgs[commandIndex] = `env OMX_DETACHED_HUD_OPERATION=${operationMarker} ${splitArgs[commandIndex]}`;
|
|
1284
|
-
splitArgs[formatIndex + 1] = `#{pane_id}\t#{pane_pid}\t#{session_id}\t#{window_id}\t${receipt}`;
|
|
2374
|
+
splitArgs[formatIndex + 1] = `#{pane_id}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}\t#{window_id}\t${receipt}`;
|
|
1285
2375
|
const output = execTmuxFileSync([
|
|
1286
2376
|
"if-shell", "-F", "-t", authority.paneId, detachedLeaderAuthorityCondition(authority),
|
|
1287
2377
|
splitArgs.map(quoteShellArg).join(" "), "display-message -p ''",
|
|
1288
2378
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1289
|
-
const [paneId, panePid, sessionId, windowId, observedReceipt, ...extra] = output.split("\t");
|
|
1290
|
-
if (!/^%[0-9]+$/.test(paneId) || !/^[1-9][0-9]*$/.test(panePid) || !/^\$[0-9]+$/.test(sessionId)
|
|
1291
|
-
|| !/^@[0-9]+$/.test(windowId) || observedReceipt !== receipt || extra.length !== 0) {
|
|
2379
|
+
const [paneId, panePid, sessionName, sessionId, sessionCreated, windowId, observedReceipt, ...extra] = output.split("\t");
|
|
2380
|
+
if (!/^%[0-9]+$/.test(paneId) || !/^[1-9][0-9]*$/.test(panePid) || !sessionName || !/^\$[0-9]+$/.test(sessionId)
|
|
2381
|
+
|| !/^\d+$/.test(sessionCreated) || !/^@[0-9]+$/.test(windowId) || observedReceipt !== receipt || extra.length !== 0) {
|
|
1292
2382
|
throw new Error("detached leader authority changed before HUD split");
|
|
1293
2383
|
}
|
|
1294
2384
|
const parsedPid = Number(panePid);
|
|
1295
2385
|
if (!Number.isSafeInteger(parsedPid) || parsedPid <= 0)
|
|
1296
2386
|
throw new Error("malformed detached HUD authority");
|
|
1297
|
-
return { paneId, panePid: parsedPid, sessionId, windowId, operationMarker };
|
|
2387
|
+
return { paneId, panePid: parsedPid, sessionName, sessionId, sessionCreated, windowId, operationMarker };
|
|
1298
2388
|
}
|
|
1299
2389
|
function readTmuxEnvValueForTarget(targetPaneId) {
|
|
1300
2390
|
if (!targetPaneId.startsWith("%"))
|
|
@@ -1418,6 +2508,34 @@ function reportOrdinaryLaunchRootConflict(error, cwd, ordinaryLaunch) {
|
|
|
1418
2508
|
"[omx] cmd.exe: set \"OMX_ROOT=%USERPROFILE%\\.omx\\instances\\second-conversation\" && omx\n" +
|
|
1419
2509
|
"[omx] The selected OMX_ROOT is used literally; OMX does not reroute or allocate one automatically.");
|
|
1420
2510
|
}
|
|
2511
|
+
/**
|
|
2512
|
+
* #3578: a detached leader's session-pointer abort previously died with the
|
|
2513
|
+
* leader pane. The validated abort code now reaches the outer launcher; print
|
|
2514
|
+
* the same bounded, actionable guidance the ordinary launch path prints.
|
|
2515
|
+
* `cwd` selects the root that produced the conflict, mirroring the ordinary
|
|
2516
|
+
* path's cwd-default gating without weakening any ownership check.
|
|
2517
|
+
*/
|
|
2518
|
+
export function reportDetachedSessionPointerGuidance(error, cwd = process.cwd()) {
|
|
2519
|
+
const candidate = findDetachedSessionPointerAbort(error);
|
|
2520
|
+
if (!candidate)
|
|
2521
|
+
return;
|
|
2522
|
+
if (candidate.code === "session_pointer_owner_conflict") {
|
|
2523
|
+
// Rebuild a well-formed SessionPointerLaunchAbort so reportOrdinaryLaunchRootConflict's
|
|
2524
|
+
// isSessionPointerLaunchAbort gate (name/committed/cwd/operation/code) accepts it,
|
|
2525
|
+
// then reuse the ordinary path's exact OMX_ROOT guidance block unchanged.
|
|
2526
|
+
reportOrdinaryLaunchRootConflict(Object.assign(new Error("Session pointer sess-live-owner conflicts with an active session; launch aborted"), {
|
|
2527
|
+
name: "SessionPointerLaunchAbort",
|
|
2528
|
+
committed: false,
|
|
2529
|
+
cwd,
|
|
2530
|
+
operation: "detached-leader-pre-bind",
|
|
2531
|
+
code: candidate.code,
|
|
2532
|
+
}), cwd, true);
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
if (candidate.code === "session_pointer_unusable") {
|
|
2536
|
+
console.error("[omx] the selected session pointer is unusable; run `omx doctor` for the exact pointer status and bounded recovery steps.");
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
1421
2539
|
function logCliOperationFailure(error) {
|
|
1422
2540
|
if (isMissingTmuxLaunchNoise(error))
|
|
1423
2541
|
return;
|
|
@@ -2273,13 +3391,41 @@ export class DetachedLaunchSafetyError extends Error {
|
|
|
2273
3391
|
cause;
|
|
2274
3392
|
report;
|
|
2275
3393
|
name = "DetachedLaunchSafetyError";
|
|
2276
|
-
|
|
2277
|
-
|
|
3394
|
+
/**
|
|
3395
|
+
* The exact bootstrap step that failed, when the failure came from a named
|
|
3396
|
+
* detached tmux step (#3578). `phase` stays the coarse D-state group; `step`
|
|
3397
|
+
* disambiguates `tag-session`, its follow-on mutations, the HUD split, and
|
|
3398
|
+
* every finalize step that previously collapsed into `pane-id`.
|
|
3399
|
+
*/
|
|
3400
|
+
step;
|
|
3401
|
+
constructor(phase, cause, report, step) {
|
|
3402
|
+
super(`detached launch safety failure during ${phase}${step && step !== phase ? ` (${step})` : ""}${cause instanceof Error ? `: ${describeDetachedLeaderFailure(cause)}` : ""}`);
|
|
2278
3403
|
this.phase = phase;
|
|
2279
3404
|
this.cause = cause;
|
|
2280
3405
|
this.report = report;
|
|
3406
|
+
if (step)
|
|
3407
|
+
this.step = step;
|
|
2281
3408
|
}
|
|
2282
3409
|
}
|
|
3410
|
+
export function isExactDetachedFinalization(finalization, expected) {
|
|
3411
|
+
return finalization !== undefined && (finalization.kind === "failed" || finalization.kind === "terminal") &&
|
|
3412
|
+
finalization.nonce === expected.nonce &&
|
|
3413
|
+
finalization.sessionId === expected.sessionId && finalization.sessionName === expected.sessionName &&
|
|
3414
|
+
finalization.leaderPid === expected.leaderPid;
|
|
3415
|
+
}
|
|
3416
|
+
function authenticatedDetachedFinalization(resolution) {
|
|
3417
|
+
if (resolution?.acknowledged !== true || !resolution.nonce || !resolution.sessionId || !resolution.sessionName ||
|
|
3418
|
+
typeof resolution.leaderPid !== "number" || !Number.isSafeInteger(resolution.leaderPid) ||
|
|
3419
|
+
(resolution.kind !== "failed" && resolution.kind !== "terminal"))
|
|
3420
|
+
return undefined;
|
|
3421
|
+
return {
|
|
3422
|
+
nonce: resolution.nonce,
|
|
3423
|
+
sessionId: resolution.sessionId,
|
|
3424
|
+
sessionName: resolution.sessionName,
|
|
3425
|
+
leaderPid: resolution.leaderPid,
|
|
3426
|
+
kind: resolution.kind,
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
2283
3429
|
export class DetachedTransportUnavailable extends Error {
|
|
2284
3430
|
reason;
|
|
2285
3431
|
name = "DetachedTransportUnavailable";
|
|
@@ -2298,6 +3444,7 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
|
|
|
2298
3444
|
let released = false;
|
|
2299
3445
|
let retainedAuthority = false;
|
|
2300
3446
|
let rollbackAuthorized = false;
|
|
3447
|
+
let finalization;
|
|
2301
3448
|
try {
|
|
2302
3449
|
transition("D1");
|
|
2303
3450
|
binding = await deps.establish();
|
|
@@ -2347,8 +3494,8 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
|
|
|
2347
3494
|
released = true;
|
|
2348
3495
|
throw new AggregateError([releaseError, abortError], "detached release and abort publication both failed");
|
|
2349
3496
|
}
|
|
2350
|
-
|
|
2351
|
-
|
|
3497
|
+
finalization = authenticatedDetachedFinalization(resolution);
|
|
3498
|
+
rollbackAuthorized = finalization !== undefined;
|
|
2352
3499
|
if (!rollbackAuthorized)
|
|
2353
3500
|
released = true;
|
|
2354
3501
|
throw releaseError;
|
|
@@ -2362,8 +3509,8 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
|
|
|
2362
3509
|
if (retainedAuthority && !rollbackAuthorized && !released) {
|
|
2363
3510
|
try {
|
|
2364
3511
|
const resolution = await deps.abortAndAwaitFinalization?.(error);
|
|
2365
|
-
|
|
2366
|
-
|
|
3512
|
+
finalization = authenticatedDetachedFinalization(resolution);
|
|
3513
|
+
rollbackAuthorized = resolution?.rollbackAuthorized === true || finalization !== undefined;
|
|
2367
3514
|
if (!rollbackAuthorized)
|
|
2368
3515
|
released = true;
|
|
2369
3516
|
}
|
|
@@ -2375,7 +3522,7 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
|
|
|
2375
3522
|
if (!released) {
|
|
2376
3523
|
try {
|
|
2377
3524
|
report.rollback.attempted.push("rollback");
|
|
2378
|
-
await deps.rollback(ownedRecord, report);
|
|
3525
|
+
await deps.rollback(ownedRecord, report, finalization);
|
|
2379
3526
|
}
|
|
2380
3527
|
catch (rollbackError) {
|
|
2381
3528
|
report.rollback.failures.push({ step: "rollback", status: "failed", code: detachedFailureCode(rollbackError) });
|
|
@@ -2383,7 +3530,7 @@ export async function executeDetachedLaunchStateMachine(input, deps) {
|
|
|
2383
3530
|
}
|
|
2384
3531
|
}
|
|
2385
3532
|
if (error instanceof DetachedLaunchSafetyError) {
|
|
2386
|
-
throw new DetachedLaunchSafetyError(error.phase, error.cause, report);
|
|
3533
|
+
throw new DetachedLaunchSafetyError(error.phase, error.cause, report, error.step);
|
|
2387
3534
|
}
|
|
2388
3535
|
const phase = report.transitions.at(-1);
|
|
2389
3536
|
const phaseName = phase === "D1" ? "establishment"
|
|
@@ -2475,6 +3622,7 @@ function renderDetachedLaunchSafetyReport(error) {
|
|
|
2475
3622
|
}));
|
|
2476
3623
|
return JSON.stringify({
|
|
2477
3624
|
phase: error.phase,
|
|
3625
|
+
...(error.step ? { step: error.step } : {}),
|
|
2478
3626
|
failure: describeDetachedLeaderFailure(error.cause),
|
|
2479
3627
|
transitions: error.report.transitions,
|
|
2480
3628
|
...(establishmentCleanup ? { establishmentCleanup } : {}),
|
|
@@ -2767,6 +3915,7 @@ export async function main(args) {
|
|
|
2767
3915
|
if (err instanceof DetachedLaunchSafetyError) {
|
|
2768
3916
|
console.error(`Error: ${err.message}`);
|
|
2769
3917
|
console.error(`[omx] detached launch safety report: ${renderDetachedLaunchSafetyReport(err)}`);
|
|
3918
|
+
reportDetachedSessionPointerGuidance(err.cause);
|
|
2770
3919
|
}
|
|
2771
3920
|
else {
|
|
2772
3921
|
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
|
@@ -3769,16 +4918,100 @@ function buildDetachedWindowsLeaderCommand(cwd, sessionName, codexCmd, sessionId
|
|
|
3769
4918
|
const invocation = `& ${quotePowerShellArg(process.execPath)} ${quotePowerShellArg(omxBin)} __detached-session-leader ${quotePowerShellArg(payload)}`;
|
|
3770
4919
|
return `powershell.exe -NoLogo -NoProfile -NonInteractive -Command ${quotePowerShellArg(invocation)}`;
|
|
3771
4920
|
}
|
|
3772
|
-
|
|
4921
|
+
/**
|
|
4922
|
+
* #3578: forward only the bounded machine-readable abort code of a leader-side
|
|
4923
|
+
* session-pointer abort into the failed report. The human reason travels in
|
|
4924
|
+
* `error`; nothing else from the nested error object is copied, so arbitrary
|
|
4925
|
+
* nested errors are never dumped into the report.
|
|
4926
|
+
*/
|
|
4927
|
+
function detachedSessionPointerAbortCode(failure) {
|
|
4928
|
+
if (isSessionPointerLaunchAbort(failure))
|
|
4929
|
+
return { abortCode: failure.code };
|
|
4930
|
+
// #3578: the leader's terminal catch wraps non-finalized failures in an
|
|
4931
|
+
// AggregateError (launch + lifecycle errors). The session-pointer abort can
|
|
4932
|
+
// only be the launch-side member, never the lifecycle error, so a bounded
|
|
4933
|
+
// one-level unwrap keeps the code without copying anything else.
|
|
4934
|
+
if (failure instanceof AggregateError) {
|
|
4935
|
+
for (const member of failure.errors) {
|
|
4936
|
+
if (isSessionPointerLaunchAbort(member))
|
|
4937
|
+
return { abortCode: member.code };
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
return {};
|
|
4941
|
+
}
|
|
4942
|
+
/**
|
|
4943
|
+
* #3578: rebuild a leader-side session-pointer abort (code + reason only) from
|
|
4944
|
+
* a validated failed report so the outer launcher can present the same
|
|
4945
|
+
* actionable guidance the ordinary launch path prints. Non-abort failures keep
|
|
4946
|
+
* a plain Error carrying the leader's bounded reason.
|
|
4947
|
+
*/
|
|
4948
|
+
function detachedLeaderFailureError(report) {
|
|
4949
|
+
const reason = report.error || "detached leader setup failed";
|
|
4950
|
+
if (!report.abortCode)
|
|
4951
|
+
return new Error(reason);
|
|
4952
|
+
const error = new Error(reason);
|
|
4953
|
+
error.code = report.abortCode;
|
|
4954
|
+
error.abortedSessionPointer = true;
|
|
4955
|
+
return error;
|
|
4956
|
+
}
|
|
4957
|
+
/** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
|
|
4958
|
+
export function detachedLeaderFailureErrorForTest(report) {
|
|
4959
|
+
return detachedLeaderFailureError({
|
|
4960
|
+
version: 1, kind: "failed", nonce: "test", sessionId: "test-session", sessionName: "test-name",
|
|
4961
|
+
...report,
|
|
4962
|
+
});
|
|
4963
|
+
}
|
|
4964
|
+
/**
|
|
4965
|
+
* #3578: recognize the bounded abort marker set by {@link detachedLeaderFailureError}
|
|
4966
|
+
* directly on an error or on the launch-side member of a wrapping AggregateError.
|
|
4967
|
+
* The D2 completion path wraps the leader's error in an AggregateError before the
|
|
4968
|
+
* outer launcher sees it; without this unwrap the actionable abort code would be
|
|
4969
|
+
* invisible to the guidance printer.
|
|
4970
|
+
*/
|
|
4971
|
+
function findDetachedSessionPointerAbort(error) {
|
|
4972
|
+
const candidates = [error];
|
|
4973
|
+
if (error instanceof AggregateError)
|
|
4974
|
+
candidates.push(...error.errors);
|
|
4975
|
+
for (const candidate of candidates) {
|
|
4976
|
+
const marker = candidate;
|
|
4977
|
+
if (marker && marker.abortedSessionPointer === true && typeof marker.code === "string") {
|
|
4978
|
+
return { code: marker.code };
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4981
|
+
return undefined;
|
|
4982
|
+
}
|
|
4983
|
+
/** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
|
|
4984
|
+
export function isDetachedSessionPointerAbortCarried(error) {
|
|
4985
|
+
return findDetachedSessionPointerAbort(error) !== undefined;
|
|
4986
|
+
}
|
|
4987
|
+
export function isDetachedReadyReportAuthorized(report, expected, platform = process.platform) {
|
|
3773
4988
|
if (report?.kind !== "ready" || report.nonce !== expected.nonce
|
|
3774
4989
|
|| report.sessionId !== expected.sessionId || report.sessionName !== expected.sessionName
|
|
3775
4990
|
|| typeof report.leaderPid !== "number" || report.leaderPid <= 0)
|
|
3776
4991
|
return false;
|
|
4992
|
+
if (platform === "win32")
|
|
4993
|
+
return report.paneId === expected.leaderPaneId;
|
|
3777
4994
|
if (expected.shouldAttach && expected.leaderPanePid !== null) {
|
|
3778
4995
|
return report.leaderPid === expected.leaderPanePid;
|
|
3779
4996
|
}
|
|
3780
4997
|
return report.paneId === expected.leaderPaneId;
|
|
3781
4998
|
}
|
|
4999
|
+
function isDetachedTerminalReportAuthorized(report, expected, platform = process.platform) {
|
|
5000
|
+
if (report?.kind !== "terminal" || report.finalized !== true)
|
|
5001
|
+
return false;
|
|
5002
|
+
return isDetachedReadyReportAuthorized({ ...report, kind: "ready" }, expected, platform);
|
|
5003
|
+
}
|
|
5004
|
+
function isDetachedFailedReportAuthorized(report, expected, platform = process.platform) {
|
|
5005
|
+
return report?.kind === "failed" && report.nonce === expected.nonce
|
|
5006
|
+
&& report.sessionId === expected.sessionId && report.sessionName === expected.sessionName
|
|
5007
|
+
&& report.paneId === expected.leaderPaneId
|
|
5008
|
+
&& typeof report.leaderPid === "number" && report.leaderPid > 0
|
|
5009
|
+
&& (platform === "win32" || expected.leaderPanePid === null || report.leaderPid === expected.leaderPanePid);
|
|
5010
|
+
}
|
|
5011
|
+
/** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
|
|
5012
|
+
export function isDetachedFailedReportAuthorizedForTest(report, expected, platform) {
|
|
5013
|
+
return isDetachedFailedReportAuthorized(report, expected, platform);
|
|
5014
|
+
}
|
|
3782
5015
|
function writeDetachedLeaderReport(path, report) {
|
|
3783
5016
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
3784
5017
|
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -3834,6 +5067,8 @@ function readDetachedLeaderReport(path) {
|
|
|
3834
5067
|
return undefined;
|
|
3835
5068
|
if (report.exitStatus !== undefined && (!Number.isSafeInteger(report.exitStatus) || Number(report.exitStatus) < 0 || Number(report.exitStatus) > 255))
|
|
3836
5069
|
return undefined;
|
|
5070
|
+
if (report.abortCode !== undefined && (typeof report.abortCode !== "string" || report.abortCode.length > 128 || !/^[a-z_]+$/.test(report.abortCode)))
|
|
5071
|
+
return undefined;
|
|
3837
5072
|
return report;
|
|
3838
5073
|
}
|
|
3839
5074
|
catch {
|
|
@@ -5045,9 +6280,11 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
|
|
|
5045
6280
|
let detachedHudAuthority = null;
|
|
5046
6281
|
let detachedLeaderPid = null;
|
|
5047
6282
|
let rollbackFromPreReportAuthority = false;
|
|
6283
|
+
let rollbackFromAuthenticatedFailure = false;
|
|
5048
6284
|
let attachStep = null;
|
|
5049
6285
|
const launchDetachedSession = async () => {
|
|
5050
6286
|
let createdSession = false;
|
|
6287
|
+
let detachedLeaderOwnerTagInstalled = false;
|
|
5051
6288
|
const bootstrapLeader = async () => {
|
|
5052
6289
|
if (!nativeWindows)
|
|
5053
6290
|
detachedParentEnvFilePath = writeDetachedSessionParentEnvFile(cwd, sessionId, codexEnvWithNotify);
|
|
@@ -5073,7 +6310,8 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
|
|
|
5073
6310
|
if (!authority)
|
|
5074
6311
|
throw new Error("detached leader authority missing before tmux mutation");
|
|
5075
6312
|
if (step.name === "tag-session") {
|
|
5076
|
-
|
|
6313
|
+
runDetachedLeaderTagMutation(authority, sessionId);
|
|
6314
|
+
detachedLeaderOwnerTagInstalled = true;
|
|
5077
6315
|
// tmux can acknowledge the session tag before the first owner-format
|
|
5078
6316
|
// evaluation sees it. Retrying this idempotent first owner-guarded
|
|
5079
6317
|
// mutation once gives the server a command boundary without relaxing
|
|
@@ -5111,7 +6349,7 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
|
|
|
5111
6349
|
}
|
|
5112
6350
|
}
|
|
5113
6351
|
catch (error) {
|
|
5114
|
-
throw new DetachedLaunchSafetyError(step.name === "new-session" ? "inert-session" : "pane-id", error, detachedPreflight.report);
|
|
6352
|
+
throw new DetachedLaunchSafetyError(step.name === "new-session" ? "inert-session" : "pane-id", error, detachedPreflight.report, step.name);
|
|
5115
6353
|
}
|
|
5116
6354
|
}
|
|
5117
6355
|
return undefined;
|
|
@@ -5134,11 +6372,34 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
|
|
|
5134
6372
|
return { kind: "success" };
|
|
5135
6373
|
}
|
|
5136
6374
|
if (report?.nonce === detachedLaunchNonce && report.sessionId === sessionId && report.sessionName === sessionName && report.leaderPid && report.kind === "failed") {
|
|
6375
|
+
if (!isDetachedFailedReportAuthorized(report, {
|
|
6376
|
+
nonce: detachedLaunchNonce,
|
|
6377
|
+
sessionId,
|
|
6378
|
+
sessionName,
|
|
6379
|
+
leaderPaneId: detachedLeaderPaneId,
|
|
6380
|
+
leaderPanePid: detachedLeaderAuthority?.panePid ?? null,
|
|
6381
|
+
})) {
|
|
6382
|
+
throw new Error("detached failed report is not authorized");
|
|
6383
|
+
}
|
|
5137
6384
|
detachedLeaderPid = report.leaderPid;
|
|
5138
|
-
|
|
6385
|
+
// #3578: a failed leader report is terminal evidence. A leader that
|
|
6386
|
+
// never reached readiness cannot hold pointer authority, so the
|
|
6387
|
+
// outer launcher owns the pre-report tmux topology it created and
|
|
6388
|
+
// may roll it back through the captured authority — never through
|
|
6389
|
+
// the wait-for-finalization path, which only a ready leader can
|
|
6390
|
+
// satisfy and would otherwise stall 30s and skip rollback.
|
|
6391
|
+
rollbackFromPreReportAuthority = detachedLeaderAuthority !== null;
|
|
6392
|
+
rollbackFromAuthenticatedFailure = rollbackFromPreReportAuthority;
|
|
6393
|
+
return { kind: "failure", operation: "session-instructions", error: detachedLeaderFailureError(report) };
|
|
5139
6394
|
}
|
|
5140
|
-
if (Date.now() >= readyDeadline)
|
|
6395
|
+
if (Date.now() >= readyDeadline) {
|
|
6396
|
+
// A timeout still owns the created pre-report topology. Keep
|
|
6397
|
+
// the marker and authorize the asynchronous watcher so a
|
|
6398
|
+
// failed report published just after this boundary can trigger
|
|
6399
|
+
// cleanup after the leader exits without blocking completion.
|
|
6400
|
+
rollbackFromPreReportAuthority = detachedLeaderAuthority !== null;
|
|
5141
6401
|
return { kind: "failure", operation: "session-instructions", error: new Error("detached leader readiness timed out") };
|
|
6402
|
+
}
|
|
5142
6403
|
blockMs(20);
|
|
5143
6404
|
}
|
|
5144
6405
|
},
|
|
@@ -5188,7 +6449,13 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
|
|
|
5188
6449
|
// The leader has the retained binding. Publishing abort is the only
|
|
5189
6450
|
// outer action permitted after a D9 write failure; a mismatched or
|
|
5190
6451
|
// missing report leaves every artifact and the tmux session intact.
|
|
5191
|
-
if (!detachedLeaderPid) {
|
|
6452
|
+
if (!detachedLeaderPid || rollbackFromPreReportAuthority) {
|
|
6453
|
+
// #3578: either the leader was never observed (classic timeout /
|
|
6454
|
+
// bootstrap abort) or it already reported a terminal failure before
|
|
6455
|
+
// readiness — in both cases it never held pointer authority, so the
|
|
6456
|
+
// pre-report tmux topology the outer launcher created is its own to
|
|
6457
|
+
// roll back. Never wait for finalization here: only a leader that
|
|
6458
|
+
// reached readiness can acknowledge it.
|
|
5192
6459
|
rollbackFromPreReportAuthority = detachedLeaderAuthority !== null;
|
|
5193
6460
|
return { acknowledged: false, rollbackAuthorized: rollbackFromPreReportAuthority };
|
|
5194
6461
|
}
|
|
@@ -5225,7 +6492,7 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
|
|
|
5225
6492
|
},
|
|
5226
6493
|
attachOrReturn: async () => { if (attachStep)
|
|
5227
6494
|
execTmuxFileSync(attachStep.args, { stdio: "inherit" }); },
|
|
5228
|
-
rollback: async (_ownedRecord, report) => {
|
|
6495
|
+
rollback: async (_ownedRecord, report, finalization) => {
|
|
5229
6496
|
const attempt = async (step, operation) => {
|
|
5230
6497
|
report.rollback.attempted.push(step);
|
|
5231
6498
|
try {
|
|
@@ -5238,20 +6505,64 @@ async function runCodex(cwd, args, sessionId, workerDefaultModel, codexHomeOverr
|
|
|
5238
6505
|
await attempt("parent-env", async () => { if (detachedParentEnvFilePath)
|
|
5239
6506
|
await rm(detachedParentEnvFilePath, { force: true }); });
|
|
5240
6507
|
await attempt("runtime-codex-home", () => cleanupRuntimeCodexHome(runtimeCodexHomeForCleanup, projectLocalCodexHomeForCleanup));
|
|
5241
|
-
|
|
6508
|
+
const removeReleaseMarkers = () => {
|
|
6509
|
+
rmSync(releaseMarkerPath, { force: true });
|
|
6510
|
+
rmSync(`${releaseMarkerPath}.release`, { force: true });
|
|
6511
|
+
rmSync(`${releaseMarkerPath}.abort`, { force: true });
|
|
6512
|
+
};
|
|
6513
|
+
let sessionCleanupCompleted = !rollbackFromPreReportAuthority;
|
|
5242
6514
|
if (createdSession) {
|
|
6515
|
+
if (isExactDetachedFinalization(finalization, {
|
|
6516
|
+
nonce: detachedLaunchNonce,
|
|
6517
|
+
sessionId,
|
|
6518
|
+
sessionName,
|
|
6519
|
+
leaderPid: detachedLeaderPid,
|
|
6520
|
+
})) {
|
|
6521
|
+
if (detachedHudAuthority) {
|
|
6522
|
+
await attempt("session:kill-bootstrap-hud", () => {
|
|
6523
|
+
if (!cleanupFinalizedDetachedHud(detachedHudAuthority, sessionId)) {
|
|
6524
|
+
throw new Error("detached bootstrap HUD authority changed before finalized cleanup");
|
|
6525
|
+
}
|
|
6526
|
+
});
|
|
6527
|
+
}
|
|
6528
|
+
await attempt("rollback", removeReleaseMarkers);
|
|
6529
|
+
return;
|
|
6530
|
+
}
|
|
5243
6531
|
for (const step of buildDetachedSessionRollbackSteps(sessionName, null, null, null)) {
|
|
5244
6532
|
await attempt(`session:${step.name}`, () => {
|
|
5245
|
-
|
|
6533
|
+
const leaderAuthority = detachedLeaderAuthority;
|
|
6534
|
+
if (!leaderAuthority)
|
|
5246
6535
|
throw new Error("detached leader authority missing before rollback");
|
|
5247
6536
|
if (rollbackFromPreReportAuthority && step.name === "kill-session") {
|
|
5248
|
-
cleanupDetachedPreReportSession(
|
|
6537
|
+
const cleanupResult = cleanupDetachedPreReportSession(leaderAuthority, !detachedLeaderOwnerTagInstalled, () => {
|
|
6538
|
+
const expected = {
|
|
6539
|
+
nonce: detachedLaunchNonce,
|
|
6540
|
+
sessionId,
|
|
6541
|
+
sessionName,
|
|
6542
|
+
shouldAttach: detachedPreflight.shouldAttach,
|
|
6543
|
+
leaderPaneId: detachedLeaderPaneId,
|
|
6544
|
+
leaderPanePid: leaderAuthority.panePid,
|
|
6545
|
+
};
|
|
6546
|
+
const report = readDetachedLeaderReport(releaseMarkerPath);
|
|
6547
|
+
return isDetachedReadyReportAuthorized(report, expected) ||
|
|
6548
|
+
isDetachedTerminalReportAuthorized(report, expected);
|
|
6549
|
+
}, () => isDetachedFailedReportAuthorized(readDetachedLeaderReport(releaseMarkerPath), {
|
|
6550
|
+
nonce: detachedLaunchNonce,
|
|
6551
|
+
sessionId,
|
|
6552
|
+
sessionName,
|
|
6553
|
+
leaderPaneId: detachedLeaderPaneId,
|
|
6554
|
+
leaderPanePid: leaderAuthority.panePid,
|
|
6555
|
+
}), rollbackFromAuthenticatedFailure);
|
|
6556
|
+
sessionCleanupCompleted = cleanupResult === "cleaned";
|
|
5249
6557
|
return;
|
|
5250
6558
|
}
|
|
5251
|
-
runDetachedLeaderMutation(
|
|
6559
|
+
runDetachedLeaderMutation(leaderAuthority, step.args);
|
|
5252
6560
|
});
|
|
5253
6561
|
}
|
|
5254
6562
|
}
|
|
6563
|
+
if (sessionCleanupCompleted) {
|
|
6564
|
+
await attempt("rollback", removeReleaseMarkers);
|
|
6565
|
+
}
|
|
5255
6566
|
},
|
|
5256
6567
|
});
|
|
5257
6568
|
if (launchTerminal.kind === "attached" && detachedLeaderPid) {
|
|
@@ -5435,13 +6746,17 @@ function parseDetachedHudAuthorityProof(value) {
|
|
|
5435
6746
|
return undefined;
|
|
5436
6747
|
if (typeof proof.panePid !== "number" || !Number.isSafeInteger(proof.panePid) || proof.panePid <= 0)
|
|
5437
6748
|
return undefined;
|
|
6749
|
+
if (typeof proof.sessionName !== "string" || proof.sessionName.length === 0 || proof.sessionName.includes("\t"))
|
|
6750
|
+
return undefined;
|
|
5438
6751
|
if (typeof proof.sessionId !== "string" || !/^\$[0-9]+$/.test(proof.sessionId))
|
|
5439
6752
|
return undefined;
|
|
6753
|
+
if (typeof proof.sessionCreated !== "string" || !/^\d+$/.test(proof.sessionCreated))
|
|
6754
|
+
return undefined;
|
|
5440
6755
|
if (typeof proof.windowId !== "string" || !/^@[0-9]+$/.test(proof.windowId))
|
|
5441
6756
|
return undefined;
|
|
5442
6757
|
if (typeof proof.operationMarker !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(proof.operationMarker))
|
|
5443
6758
|
return undefined;
|
|
5444
|
-
return { paneId: proof.paneId, panePid: proof.panePid, sessionId: proof.sessionId, windowId: proof.windowId, operationMarker: proof.operationMarker };
|
|
6759
|
+
return { paneId: proof.paneId, panePid: proof.panePid, sessionName: proof.sessionName, sessionId: proof.sessionId, sessionCreated: proof.sessionCreated, windowId: proof.windowId, operationMarker: proof.operationMarker };
|
|
5445
6760
|
}
|
|
5446
6761
|
function teardownDetachedOwnedHudPane(leaderPaneId, payload, hudProof) {
|
|
5447
6762
|
// #3266: on a normal child exit, kill exactly the bootstrap-proven OMX HUD pane so
|
|
@@ -5450,13 +6765,13 @@ function teardownDetachedOwnedHudPane(leaderPaneId, payload, hudProof) {
|
|
|
5450
6765
|
// Contract: only the proven HUD is removed. A pane the user added to the detached
|
|
5451
6766
|
// session is deliberately preserved, which intentionally keeps that session (and any
|
|
5452
6767
|
// attached client) alive; natural closure and shell return happen only when no other
|
|
5453
|
-
// panes remain.
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
if (!proof)
|
|
6768
|
+
// panes remain. The leader pane is removed only after the proven HUD removal is
|
|
6769
|
+
// confirmed; a stale, missing, or changed HUD proof leaves the leader untouched too.
|
|
6770
|
+
if (!hudProof)
|
|
5457
6771
|
return;
|
|
5458
6772
|
try {
|
|
5459
|
-
cleanupDetachedHudPane(
|
|
6773
|
+
if (!cleanupDetachedHudPane(hudProof, payload.sessionId))
|
|
6774
|
+
return;
|
|
5460
6775
|
execTmuxFileSync(["kill-pane", "-t", leaderPaneId], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
5461
6776
|
}
|
|
5462
6777
|
catch (error) {
|
|
@@ -5464,6 +6779,43 @@ function teardownDetachedOwnedHudPane(leaderPaneId, payload, hudProof) {
|
|
|
5464
6779
|
logCliOperationFailure(error);
|
|
5465
6780
|
}
|
|
5466
6781
|
}
|
|
6782
|
+
function cleanupDetachedLeaderSessionWithAuthority(authority, ownerId, beforeSink) {
|
|
6783
|
+
try {
|
|
6784
|
+
const owner = `#{||:#{==:#{@omx_instance_id},${ownerId}},#{&&:#{==:#{@omx_instance_id},},#{!=:#{@omx_detached_owner_tag_attempted},1}}}`;
|
|
6785
|
+
const condition = [
|
|
6786
|
+
`#{==:#{session_name},${authority.sessionName}}`,
|
|
6787
|
+
`#{==:#{session_id},${authority.sessionId}}`,
|
|
6788
|
+
`#{==:#{session_created},${authority.sessionCreated}}`,
|
|
6789
|
+
owner,
|
|
6790
|
+
].reduce((combined, item) => `#{&&:${combined},${item}}`);
|
|
6791
|
+
const receipt = detachedAuthorityReceipt();
|
|
6792
|
+
const success = `kill-session -t ${quoteShellArg(authority.sessionName)} ; display-message -p ${quoteShellArg(receipt)}`;
|
|
6793
|
+
beforeSink?.();
|
|
6794
|
+
const result = execTmuxFileSync([
|
|
6795
|
+
"if-shell", "-F", "-t", authority.sessionName, condition, success, "display-message -p ''",
|
|
6796
|
+
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
6797
|
+
if (result !== receipt)
|
|
6798
|
+
throw new Error("detached failed leader session authority changed before cleanup");
|
|
6799
|
+
}
|
|
6800
|
+
catch {
|
|
6801
|
+
// The parent retains the report and its own identity-fenced cleanup marker.
|
|
6802
|
+
// A failed leader must never turn an uncertain session lookup into a kill.
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
function cleanupDetachedLeaderSessionAfterFailure(paneId, payload) {
|
|
6806
|
+
try {
|
|
6807
|
+
const authority = captureDetachedLeaderAuthority(paneId, payload.sessionName, payload.sessionId);
|
|
6808
|
+
cleanupDetachedLeaderSessionWithAuthority(authority, payload.sessionId);
|
|
6809
|
+
}
|
|
6810
|
+
catch {
|
|
6811
|
+
// The parent retains the report and its own identity-fenced cleanup marker.
|
|
6812
|
+
// A failed leader must never turn an uncertain session lookup into a kill.
|
|
6813
|
+
}
|
|
6814
|
+
}
|
|
6815
|
+
/** Internal detached-launch seam. Exported solely for deterministic CLI tests. */
|
|
6816
|
+
export function cleanupDetachedLeaderSessionAfterFailureForTest(authority, ownerId, beforeSink) {
|
|
6817
|
+
cleanupDetachedLeaderSessionWithAuthority(authority, ownerId, beforeSink);
|
|
6818
|
+
}
|
|
5467
6819
|
function traceDetachedLeaderPhase(phase) {
|
|
5468
6820
|
if (process.env.OMX_TEST_DETACHED_TRACE !== "1")
|
|
5469
6821
|
return;
|
|
@@ -5479,32 +6831,58 @@ async function runDetachedSessionLeader(payload) {
|
|
|
5479
6831
|
continue;
|
|
5480
6832
|
process.env[key] = value;
|
|
5481
6833
|
}
|
|
6834
|
+
const nonce = payload.readyPath?.split(".").at(-2) || payload.sessionId;
|
|
5482
6835
|
let pane;
|
|
5483
6836
|
const inheritedPane = process.env.TMUX_PANE?.trim();
|
|
5484
|
-
if (
|
|
5485
|
-
if (
|
|
5486
|
-
|
|
6837
|
+
if (process.platform !== "win32") {
|
|
6838
|
+
if (payload.preLaunchOptions.shouldAttach === false) {
|
|
6839
|
+
if (!inheritedPane || !/^%[0-9]+$/.test(inheritedPane))
|
|
6840
|
+
throw new Error("detached Hermes leader has no tmux pane identity");
|
|
6841
|
+
pane = inheritedPane;
|
|
6842
|
+
}
|
|
6843
|
+
else {
|
|
6844
|
+
const paneSnapshot = execTmuxFileSync(["list-panes", "-t", payload.sessionName, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_pid}"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
6845
|
+
pane = parseDetachedLeaderPaneIdByPid(paneSnapshot, process.pid);
|
|
5487
6846
|
}
|
|
5488
|
-
pane = inheritedPane;
|
|
5489
6847
|
}
|
|
5490
6848
|
else {
|
|
5491
|
-
|
|
5492
|
-
pane = parseDetachedLeaderPaneIdByPid(paneSnapshot, process.pid);
|
|
6849
|
+
pane = resolveDetachedLeaderPaneIdentity(payload.sessionName, undefined, inheritedPane, process.platform, process.pid, payload.preLaunchOptions.shouldAttach === false, (paneId) => execTmuxFileSync(["display-message", "-p", "-t", paneId, "#{session_name}\t#{session_id}\t#{pane_id}"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(), () => execTmuxFileSync(["list-panes", "-t", payload.sessionName, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_pid}"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }));
|
|
5493
6850
|
}
|
|
5494
6851
|
process.env.TMUX_PANE = pane;
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
6852
|
+
// #3578: a pre-binding failure (notably session_pointer_owner_conflict) used to
|
|
6853
|
+
// die with the pane — remain-on-exit is off, so this leader's stderr vanished and
|
|
6854
|
+
// the outer launcher could only report a readiness failure. Record the same
|
|
6855
|
+
// bounded failure string the in-try path records so the parent can surface the
|
|
6856
|
+
// actionable abort instead of a generic detached phase. Keep pane resolution
|
|
6857
|
+
// outside the try so the exact clean-base identity path is preserved.
|
|
6858
|
+
let established;
|
|
6859
|
+
try {
|
|
6860
|
+
established = await establishPreLaunchBinding(payload.cwd, payload.sessionId, {
|
|
6861
|
+
tmuxSessionName: payload.sessionName,
|
|
6862
|
+
tmuxPaneId: pane,
|
|
6863
|
+
});
|
|
6864
|
+
}
|
|
6865
|
+
catch (error) {
|
|
6866
|
+
if (payload.readyPath) {
|
|
6867
|
+
writeDetachedLeaderReport(payload.readyPath, {
|
|
6868
|
+
version: 1, kind: "failed", nonce, sessionId: payload.sessionId, sessionName: payload.sessionName,
|
|
6869
|
+
paneId: pane,
|
|
6870
|
+
leaderPid: process.pid, error: describeDetachedLeaderFailure(error),
|
|
6871
|
+
...detachedSessionPointerAbortCode(error),
|
|
6872
|
+
});
|
|
6873
|
+
}
|
|
6874
|
+
cleanupDetachedLeaderSessionAfterFailure(pane, payload);
|
|
6875
|
+
throw error;
|
|
6876
|
+
}
|
|
5499
6877
|
const binding = established.binding;
|
|
5500
6878
|
let ownedRecord;
|
|
5501
6879
|
let detachedHudProof;
|
|
5502
|
-
const nonce = payload.readyPath?.split(".").at(-2) || payload.sessionId;
|
|
5503
6880
|
const contextKey = process.env[OMX_MADMAX_DETACHED_CONTEXT_ENV]?.trim();
|
|
6881
|
+
let externalInterrupt;
|
|
5504
6882
|
const activeRecordPath = contextKey
|
|
5505
6883
|
? madmaxDetachedActiveRecordPath(resolveMadmaxRunsRoot(process.env), contextKey)
|
|
5506
6884
|
: join(omxRoot(payload.cwd), "state", "detached-active-record.json");
|
|
5507
|
-
let
|
|
6885
|
+
let readyReportWritten = false;
|
|
5508
6886
|
try {
|
|
5509
6887
|
const completion = await completePreLaunchSetup(payload.cwd, payload.sessionId, payload.preLaunchOptions.notifyTempContract, payload.codexHomeOverride, payload.preLaunchOptions.enableNotifyFallbackAuthority, payload.preLaunchOptions.worktreeDirty);
|
|
5510
6888
|
if (completion.kind === "failure") {
|
|
@@ -5531,6 +6909,7 @@ async function runDetachedSessionLeader(payload) {
|
|
|
5531
6909
|
});
|
|
5532
6910
|
if (payload.readyPath) {
|
|
5533
6911
|
writeDetachedLeaderReport(payload.readyPath, { version: 1, kind: "ready", nonce, sessionId: payload.sessionId, sessionName: payload.sessionName, paneId: pane, leaderPid: process.pid });
|
|
6912
|
+
readyReportWritten = true;
|
|
5534
6913
|
const releasePath = `${payload.readyPath}.release`;
|
|
5535
6914
|
const abortPath = `${payload.readyPath}.abort`;
|
|
5536
6915
|
const releaseDeadline = Date.now() + 30_000;
|
|
@@ -5667,8 +7046,12 @@ async function runDetachedSessionLeader(payload) {
|
|
|
5667
7046
|
if (payload.readyPath)
|
|
5668
7047
|
writeDetachedLeaderReport(payload.readyPath, {
|
|
5669
7048
|
version: 1, kind: "failed", nonce, sessionId: payload.sessionId, sessionName: payload.sessionName,
|
|
7049
|
+
paneId: pane,
|
|
5670
7050
|
leaderPid: process.pid, finalized, error: failure instanceof Error ? failure.message : String(failure),
|
|
7051
|
+
...detachedSessionPointerAbortCode(failure),
|
|
5671
7052
|
});
|
|
7053
|
+
if (!readyReportWritten)
|
|
7054
|
+
cleanupDetachedLeaderSessionAfterFailure(pane, payload);
|
|
5672
7055
|
throw failure;
|
|
5673
7056
|
}
|
|
5674
7057
|
finally {
|
|
@@ -6388,6 +7771,58 @@ async function listHookVisibleRunDirStateRefs(cwd) {
|
|
|
6388
7771
|
}
|
|
6389
7772
|
return refs.sort((a, b) => a.mode.localeCompare(b.mode));
|
|
6390
7773
|
}
|
|
7774
|
+
function detachedIdentity(stat) {
|
|
7775
|
+
return {
|
|
7776
|
+
dev: stat.dev,
|
|
7777
|
+
ino: stat.ino,
|
|
7778
|
+
mode: stat.mode,
|
|
7779
|
+
nlink: stat.nlink,
|
|
7780
|
+
kind: stat.isDirectory() ? "directory" : "file",
|
|
7781
|
+
size: stat.size,
|
|
7782
|
+
mtimeNs: stat.mtimeNs,
|
|
7783
|
+
ctimeNs: stat.ctimeNs,
|
|
7784
|
+
};
|
|
7785
|
+
}
|
|
7786
|
+
function detachedIdentityMatches(left, right) {
|
|
7787
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode
|
|
7788
|
+
&& left.nlink === right.nlink && left.kind === right.kind && left.size === right.size
|
|
7789
|
+
&& left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
7790
|
+
}
|
|
7791
|
+
function detachedDirectoryIdentityMatches(left, right) {
|
|
7792
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode
|
|
7793
|
+
&& left.nlink === right.nlink && left.kind === "directory" && right.kind === "directory";
|
|
7794
|
+
}
|
|
7795
|
+
async function captureDetachedDirectory(path) {
|
|
7796
|
+
const before = await lstat(path, { bigint: true });
|
|
7797
|
+
if (!before.isDirectory() || before.isSymbolicLink() || await realpath(path) !== path) {
|
|
7798
|
+
throw new Error(`detached authority directory is not canonical: ${path}`);
|
|
7799
|
+
}
|
|
7800
|
+
return detachedIdentity(before);
|
|
7801
|
+
}
|
|
7802
|
+
async function readDetachedPinnedFile(path) {
|
|
7803
|
+
const before = await lstat(path, { bigint: true });
|
|
7804
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
7805
|
+
throw new Error(`detached authority file is not regular: ${path}`);
|
|
7806
|
+
const handle = await open(path, fsConstants.O_RDONLY | historyNoFollowFlag());
|
|
7807
|
+
try {
|
|
7808
|
+
const opened = await handle.stat({ bigint: true });
|
|
7809
|
+
if (!opened.isFile() || !detachedIdentityMatches(detachedIdentity(before), detachedIdentity(opened))) {
|
|
7810
|
+
throw new Error(`detached authority file changed before open: ${path}`);
|
|
7811
|
+
}
|
|
7812
|
+
const content = (await handle.readFile()).toString("utf8");
|
|
7813
|
+
const afterHandle = await handle.stat({ bigint: true });
|
|
7814
|
+
const afterPath = await lstat(path, { bigint: true });
|
|
7815
|
+
const frozen = detachedIdentity(before);
|
|
7816
|
+
if (!detachedIdentityMatches(frozen, detachedIdentity(afterHandle))
|
|
7817
|
+
|| !detachedIdentityMatches(frozen, detachedIdentity(afterPath))) {
|
|
7818
|
+
throw new Error(`detached authority file changed during read: ${path}`);
|
|
7819
|
+
}
|
|
7820
|
+
return { identity: frozen, content };
|
|
7821
|
+
}
|
|
7822
|
+
finally {
|
|
7823
|
+
await handle.close();
|
|
7824
|
+
}
|
|
7825
|
+
}
|
|
6391
7826
|
function isCanonicalPathWithin(parent, child, allowEqual = false) {
|
|
6392
7827
|
const rel = relative(parent, child);
|
|
6393
7828
|
if (rel === "")
|
|
@@ -6425,18 +7860,80 @@ async function selectAuthorizedHookVisibleRunDirState(cwd) {
|
|
|
6425
7860
|
if (!isCanonicalPathWithin(canonicalRunsRoot, canonicalRunDir)) {
|
|
6426
7861
|
throw new Error("run directory escapes the authorized runs root");
|
|
6427
7862
|
}
|
|
6428
|
-
const
|
|
7863
|
+
const omxDir = join(canonicalRunDir, ".omx");
|
|
7864
|
+
const stateDir = join(omxDir, "state");
|
|
7865
|
+
const sessionsDir = join(stateDir, "sessions");
|
|
7866
|
+
const treeDirectories = new Map();
|
|
7867
|
+
for (const path of [canonicalRunDir, omxDir, stateDir, sessionsDir]) {
|
|
7868
|
+
treeDirectories.set(path, await captureDetachedDirectory(path));
|
|
7869
|
+
}
|
|
6429
7870
|
if (!isCanonicalPathWithin(canonicalRunDir, stateDir)) {
|
|
6430
7871
|
throw new Error("state directory escapes the authorized run directory");
|
|
6431
7872
|
}
|
|
6432
|
-
const
|
|
7873
|
+
const protectedFiles = new Map();
|
|
7874
|
+
const pointerPath = join(stateDir, "session.json");
|
|
7875
|
+
const pointer = await readDetachedPinnedFile(pointerPath);
|
|
7876
|
+
protectedFiles.set(pointerPath, pointer);
|
|
7877
|
+
const session = JSON.parse(pointer.content);
|
|
6433
7878
|
if (session.session_id !== record.session_id)
|
|
6434
7879
|
throw new Error("run session pointer changed");
|
|
6435
|
-
const sessionDir =
|
|
7880
|
+
const sessionDir = join(sessionsDir, record.session_id);
|
|
7881
|
+
treeDirectories.set(sessionDir, await captureDetachedDirectory(sessionDir));
|
|
6436
7882
|
if (!isCanonicalPathWithin(stateDir, sessionDir)) {
|
|
6437
7883
|
throw new Error("session directory escapes the authorized state directory");
|
|
6438
7884
|
}
|
|
6439
|
-
|
|
7885
|
+
const refs = [];
|
|
7886
|
+
const stateFiles = await readdir(sessionDir).catch(() => []);
|
|
7887
|
+
for (const stateFile of stateFiles) {
|
|
7888
|
+
if (!isModeStateFilename(stateFile))
|
|
7889
|
+
continue;
|
|
7890
|
+
const path = join(sessionDir, stateFile);
|
|
7891
|
+
const snapshot = await readDetachedPinnedFile(path);
|
|
7892
|
+
protectedFiles.set(path, snapshot);
|
|
7893
|
+
refs.push({ mode: stateFile.slice(0, -"-state.json".length), path, scope: "session" });
|
|
7894
|
+
}
|
|
7895
|
+
const advisoryRootPath = join(sessionDir, "ralplan-advisory");
|
|
7896
|
+
let advisoryGenerationDir;
|
|
7897
|
+
let advisoryGenerationEntries;
|
|
7898
|
+
try {
|
|
7899
|
+
treeDirectories.set(advisoryRootPath, await captureDetachedDirectory(advisoryRootPath));
|
|
7900
|
+
const currentPath = join(advisoryRootPath, "current.json");
|
|
7901
|
+
const current = await readDetachedPinnedFile(currentPath);
|
|
7902
|
+
protectedFiles.set(currentPath, current);
|
|
7903
|
+
const generationId = JSON.parse(current.content).generation_id;
|
|
7904
|
+
if (typeof generationId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(generationId)) {
|
|
7905
|
+
throw new Error("detached Advisory current generation is invalid");
|
|
7906
|
+
}
|
|
7907
|
+
const generationPath = join(advisoryRootPath, generationId);
|
|
7908
|
+
advisoryGenerationDir = generationPath;
|
|
7909
|
+
treeDirectories.set(generationPath, await captureDetachedDirectory(generationPath));
|
|
7910
|
+
advisoryGenerationEntries = new Set(await readdir(generationPath));
|
|
7911
|
+
for (const name of ["fence.json", "closeout-journal.json"]) {
|
|
7912
|
+
const path = join(generationPath, name);
|
|
7913
|
+
try {
|
|
7914
|
+
protectedFiles.set(path, await readDetachedPinnedFile(path));
|
|
7915
|
+
}
|
|
7916
|
+
catch (error) {
|
|
7917
|
+
if (error.code !== "ENOENT")
|
|
7918
|
+
throw error;
|
|
7919
|
+
}
|
|
7920
|
+
}
|
|
7921
|
+
}
|
|
7922
|
+
catch (error) {
|
|
7923
|
+
if (error.code !== "ENOENT")
|
|
7924
|
+
throw error;
|
|
7925
|
+
}
|
|
7926
|
+
candidates.push({
|
|
7927
|
+
refs: refs.sort((a, b) => a.mode.localeCompare(b.mode)),
|
|
7928
|
+
sessionDir,
|
|
7929
|
+
sessionId: record.session_id,
|
|
7930
|
+
runCwd: canonicalRunDir,
|
|
7931
|
+
treeDirectories,
|
|
7932
|
+
protectedFiles,
|
|
7933
|
+
...(advisoryGenerationDir ? { advisoryGenerationDir } : {}),
|
|
7934
|
+
...(advisoryGenerationEntries ? { advisoryGenerationEntries } : {}),
|
|
7935
|
+
record,
|
|
7936
|
+
});
|
|
6440
7937
|
}
|
|
6441
7938
|
catch (err) {
|
|
6442
7939
|
throw new Error(`Refusing cancellation because detached run authority is invalid: ${record.run_dir}.`, { cause: err });
|
|
@@ -6446,33 +7943,7 @@ async function selectAuthorizedHookVisibleRunDirState(cwd) {
|
|
|
6446
7943
|
throw new Error("Refusing cancellation because multiple detached run authorities match.");
|
|
6447
7944
|
if (candidates.length === 0)
|
|
6448
7945
|
return null;
|
|
6449
|
-
|
|
6450
|
-
const refs = [];
|
|
6451
|
-
const stateFiles = await readdir(sessionDir).catch(() => []);
|
|
6452
|
-
for (const file of stateFiles) {
|
|
6453
|
-
if (!isModeStateFilename(file))
|
|
6454
|
-
continue;
|
|
6455
|
-
const path = join(sessionDir, file);
|
|
6456
|
-
try {
|
|
6457
|
-
const fileStat = lstatSync(path);
|
|
6458
|
-
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
|
|
6459
|
-
throw new Error(`Refusing cancellation through non-regular run state target: ${path}.`);
|
|
6460
|
-
}
|
|
6461
|
-
const canonicalFile = realpathSync(path);
|
|
6462
|
-
if (!isCanonicalPathWithin(sessionDir, canonicalFile)) {
|
|
6463
|
-
throw new Error(`Refusing cancellation outside authorized run session: ${path}.`);
|
|
6464
|
-
}
|
|
6465
|
-
refs.push({
|
|
6466
|
-
mode: file.slice(0, -"-state.json".length),
|
|
6467
|
-
path: canonicalFile,
|
|
6468
|
-
scope: "session",
|
|
6469
|
-
});
|
|
6470
|
-
}
|
|
6471
|
-
catch (err) {
|
|
6472
|
-
throw new Error(`Refusing cancellation because detached run state authority is invalid: ${path}.`, { cause: err });
|
|
6473
|
-
}
|
|
6474
|
-
}
|
|
6475
|
-
return { refs: refs.sort((a, b) => a.mode.localeCompare(b.mode)), sessionId, sessionDir, record };
|
|
7946
|
+
return candidates[0];
|
|
6476
7947
|
}
|
|
6477
7948
|
function assertCancellationAuthorityPath(baseStateDir, authorityRoot) {
|
|
6478
7949
|
const resolvedBase = resolve(baseStateDir);
|
|
@@ -6614,6 +8085,9 @@ function assertCompatibleNestedSkillOwners(value, authority, path) {
|
|
|
6614
8085
|
assertExactSkillOwner(entry, authority, entryPath);
|
|
6615
8086
|
}
|
|
6616
8087
|
}
|
|
8088
|
+
export function cancellationFileIdentityMatches(frozen, current, platform = process.platform) {
|
|
8089
|
+
return sessionOwnerFileIdentityMatches(frozen, current, platform);
|
|
8090
|
+
}
|
|
6617
8091
|
async function cancelModes(args = [], options = {}) {
|
|
6618
8092
|
const cwd = options.cwd ?? process.cwd();
|
|
6619
8093
|
const nowIso = new Date().toISOString();
|
|
@@ -6631,23 +8105,24 @@ async function cancelModes(args = [], options = {}) {
|
|
|
6631
8105
|
? await resolveExactSessionCancellationAuthority(cwd, baseStateDir, writableScope.sessionId)
|
|
6632
8106
|
: undefined;
|
|
6633
8107
|
const expectedOwnerIds = collectCancellationOwnerIds(baseStateDir, writableScope.sessionId);
|
|
6634
|
-
const loadStates = async (refs, authorityRoot, ownerIds = expectedOwnerIds) => {
|
|
8108
|
+
const loadStates = async (refs, authorityRoot, ownerIds = expectedOwnerIds, pinnedFiles) => {
|
|
6635
8109
|
const loaded = new Map();
|
|
6636
8110
|
if (refs.length === 0)
|
|
6637
8111
|
return loaded;
|
|
6638
8112
|
const canonicalAuthorityRoot = assertCancellationAuthorityPath(authorityRoot === writableScope.stateDir ? baseStateDir : authorityRoot, authorityRoot);
|
|
6639
8113
|
for (const ref of refs) {
|
|
6640
|
-
const
|
|
6641
|
-
|
|
8114
|
+
const pinned = pinnedFiles?.get(ref.path);
|
|
8115
|
+
const fileStat = pinned ? null : lstatSync(ref.path);
|
|
8116
|
+
if (fileStat && (!fileStat.isFile() || fileStat.isSymbolicLink())) {
|
|
6642
8117
|
throw new Error(`Refusing cancellation through non-regular state target ${ref.path}.`);
|
|
6643
8118
|
}
|
|
6644
|
-
const canonicalPath = realpathSync(ref.path);
|
|
8119
|
+
const canonicalPath = pinned ? ref.path : realpathSync(ref.path);
|
|
6645
8120
|
const canonicalParent = realpathSync(dirname(ref.path));
|
|
6646
8121
|
if (!isCanonicalPathWithin(canonicalAuthorityRoot, canonicalParent, true)
|
|
6647
8122
|
|| !isCanonicalPathWithin(canonicalAuthorityRoot, canonicalPath)) {
|
|
6648
8123
|
throw new Error(`Refusing cancellation outside authorized state root: ${ref.path}.`);
|
|
6649
8124
|
}
|
|
6650
|
-
const content = await readFile(canonicalPath, "utf-8");
|
|
8125
|
+
const content = pinned?.content ?? await readFile(canonicalPath, "utf-8");
|
|
6651
8126
|
let parsedState;
|
|
6652
8127
|
try {
|
|
6653
8128
|
const parsed = JSON.parse(content);
|
|
@@ -6708,8 +8183,8 @@ async function cancelModes(args = [], options = {}) {
|
|
|
6708
8183
|
scope: ref.scope,
|
|
6709
8184
|
state: parsedState,
|
|
6710
8185
|
originalContent: content,
|
|
6711
|
-
dev: fileStat.dev,
|
|
6712
|
-
ino: fileStat.ino,
|
|
8186
|
+
dev: pinned ? Number(pinned.identity.dev) : fileStat.dev,
|
|
8187
|
+
ino: pinned ? Number(pinned.identity.ino) : fileStat.ino,
|
|
6713
8188
|
});
|
|
6714
8189
|
}
|
|
6715
8190
|
return loaded;
|
|
@@ -6736,14 +8211,208 @@ async function cancelModes(args = [], options = {}) {
|
|
|
6736
8211
|
runSelection = await selectAuthorizedHookVisibleRunDirState(cwd);
|
|
6737
8212
|
if (runSelection) {
|
|
6738
8213
|
const runOwnerIds = new Set([runSelection.sessionId]);
|
|
6739
|
-
const runDirStates = await loadStates(runSelection.refs, runSelection.sessionDir, runOwnerIds);
|
|
8214
|
+
const runDirStates = await loadStates(runSelection.refs, runSelection.sessionDir, runOwnerIds, runSelection.protectedFiles);
|
|
6740
8215
|
if (hasActiveWorkflowMode(runDirStates))
|
|
6741
8216
|
states = runDirStates;
|
|
6742
8217
|
else
|
|
6743
8218
|
runSelection = null;
|
|
6744
8219
|
}
|
|
6745
8220
|
}
|
|
8221
|
+
const assertRunAuthority = () => {
|
|
8222
|
+
if (runSelection && !hasMatchingMadmaxDetachedRuntimeBinding(runSelection.record)) {
|
|
8223
|
+
throw new Error("Refusing cancellation because detached run authority changed.");
|
|
8224
|
+
}
|
|
8225
|
+
};
|
|
6746
8226
|
const currentSessionId = writableScope.sessionId ?? runSelection?.sessionId ?? "";
|
|
8227
|
+
const activeAdvisory = states.get("ralplan");
|
|
8228
|
+
if (activeAdvisory?.state.active === true && activeAdvisory.state.workflow_variant === "advisory") {
|
|
8229
|
+
if (!currentSessionId)
|
|
8230
|
+
throw new Error("Refusing Advisory cancellation without an authoritative session scope.");
|
|
8231
|
+
const { cancelRalplanConsensus } = await import("../ralplan/runtime.js");
|
|
8232
|
+
const cancellationCwd = runSelection ? runSelection.runCwd : cwd;
|
|
8233
|
+
let expectedModeIdentity = runSelection?.protectedFiles.get(activeAdvisory.path)?.identity;
|
|
8234
|
+
let expectedModeContent = activeAdvisory.originalContent;
|
|
8235
|
+
const detachedSessionSkillBaseline = runSelection
|
|
8236
|
+
? [...runSelection.protectedFiles.entries()].find(([path]) => basename(path) === "skill-active-state.json"
|
|
8237
|
+
&& path.split(sep).includes("sessions") && path.split(sep).includes(currentSessionId))?.[1].content
|
|
8238
|
+
: undefined;
|
|
8239
|
+
const detachedRootSkillBaseline = runSelection
|
|
8240
|
+
? runSelection.protectedFiles.get(join(dirname(dirname(runSelection.sessionDir)), "skill-active-state.json"))?.content
|
|
8241
|
+
: undefined;
|
|
8242
|
+
let sessionModeMutationPending = false;
|
|
8243
|
+
const pendingProtectedMutations = new Set();
|
|
8244
|
+
const revalidateDetachedAdvisoryAuthority = runSelection
|
|
8245
|
+
? async (checkpoint) => {
|
|
8246
|
+
if (checkpoint === "mirror_session_mode" || checkpoint === "cancel_mode_update"
|
|
8247
|
+
|| checkpoint === "mode_update_complete"
|
|
8248
|
+
|| checkpoint === "mode_commit_ralplan.session-state") {
|
|
8249
|
+
sessionModeMutationPending = true;
|
|
8250
|
+
}
|
|
8251
|
+
assertRunAuthority();
|
|
8252
|
+
const stateDir = dirname(dirname(runSelection.sessionDir));
|
|
8253
|
+
for (const [path, frozen] of runSelection.treeDirectories) {
|
|
8254
|
+
let current;
|
|
8255
|
+
try {
|
|
8256
|
+
current = await captureDetachedDirectory(path);
|
|
8257
|
+
}
|
|
8258
|
+
catch (error) {
|
|
8259
|
+
throw new Error(`Refusing cancellation because detached directory is no longer canonical: ${path}.`, { cause: error });
|
|
8260
|
+
}
|
|
8261
|
+
if (!detachedDirectoryIdentityMatches(frozen, current)) {
|
|
8262
|
+
const isGeneration = path === runSelection.advisoryGenerationDir;
|
|
8263
|
+
const stableObject = frozen.dev === current.dev && frozen.ino === current.ino
|
|
8264
|
+
&& frozen.mode === current.mode && frozen.kind === current.kind;
|
|
8265
|
+
if (isGeneration && stableObject && runSelection.advisoryGenerationEntries) {
|
|
8266
|
+
const entries = new Set(await readdir(path));
|
|
8267
|
+
const allowed = [...entries].every((entry) => runSelection.advisoryGenerationEntries.has(entry)
|
|
8268
|
+
|| entry === "generation.lock" || entry === "fence.json" || entry === "closeout-journal.json"
|
|
8269
|
+
|| entry === "admin-event-0001.json"
|
|
8270
|
+
|| /^fence-event-[0-9]{4}\.json$/.test(entry));
|
|
8271
|
+
if (allowed) {
|
|
8272
|
+
runSelection.advisoryGenerationEntries = entries;
|
|
8273
|
+
runSelection.treeDirectories.set(path, current);
|
|
8274
|
+
continue;
|
|
8275
|
+
}
|
|
8276
|
+
}
|
|
8277
|
+
if (stableObject) {
|
|
8278
|
+
runSelection.treeDirectories.set(path, current);
|
|
8279
|
+
continue;
|
|
8280
|
+
}
|
|
8281
|
+
throw new Error(`Refusing cancellation because detached directory identity changed at ${checkpoint}: ${path}.`);
|
|
8282
|
+
}
|
|
8283
|
+
}
|
|
8284
|
+
const pointerPath = join(stateDir, "session.json");
|
|
8285
|
+
const pointerSnapshot = await readDetachedPinnedFile(pointerPath);
|
|
8286
|
+
const frozenPointer = runSelection.protectedFiles.get(pointerPath);
|
|
8287
|
+
if (!frozenPointer || !detachedIdentityMatches(frozenPointer.identity, pointerSnapshot.identity)
|
|
8288
|
+
|| frozenPointer.content !== pointerSnapshot.content) {
|
|
8289
|
+
throw new Error("Refusing cancellation because detached session pointer changed.");
|
|
8290
|
+
}
|
|
8291
|
+
const pointer = JSON.parse(pointerSnapshot.content);
|
|
8292
|
+
if (pointer.session_id !== currentSessionId) {
|
|
8293
|
+
throw new Error("Refusing cancellation because detached session authority changed.");
|
|
8294
|
+
}
|
|
8295
|
+
for (const [path, frozen] of [...runSelection.protectedFiles]) {
|
|
8296
|
+
if (path === pointerPath || path === activeAdvisory.path)
|
|
8297
|
+
continue;
|
|
8298
|
+
const snapshot = await readDetachedPinnedFile(path);
|
|
8299
|
+
if (!detachedIdentityMatches(frozen.identity, snapshot.identity) || frozen.content !== snapshot.content) {
|
|
8300
|
+
const isPendingSessionSkill = pendingProtectedMutations.has(path)
|
|
8301
|
+
&& basename(path) === "skill-active-state.json"
|
|
8302
|
+
&& path.split(sep).includes("sessions")
|
|
8303
|
+
&& path.split(sep).includes(currentSessionId);
|
|
8304
|
+
if (isPendingSessionSkill && checkpoint !== "journal_step_session_skill")
|
|
8305
|
+
continue;
|
|
8306
|
+
if (!pendingProtectedMutations.delete(path)) {
|
|
8307
|
+
throw new Error(`Refusing cancellation because detached protected state changed at ${checkpoint}: ${path}.`);
|
|
8308
|
+
}
|
|
8309
|
+
if (basename(path) === "skill-active-state.json"
|
|
8310
|
+
&& path.split(sep).includes("sessions")
|
|
8311
|
+
&& path.split(sep).includes(currentSessionId)) {
|
|
8312
|
+
const journalPath = runSelection.advisoryGenerationDir
|
|
8313
|
+
? join(runSelection.advisoryGenerationDir, "closeout-journal.json") : "";
|
|
8314
|
+
const journalSnapshot = journalPath ? runSelection.protectedFiles.get(journalPath) : undefined;
|
|
8315
|
+
const journal = journalSnapshot
|
|
8316
|
+
? JSON.parse(journalSnapshot.content) : null;
|
|
8317
|
+
const terminalSkillUpdates = journal && typeof journal.terminal_skill_updates === "object"
|
|
8318
|
+
&& journal.terminal_skill_updates !== null
|
|
8319
|
+
? journal.terminal_skill_updates : null;
|
|
8320
|
+
const expectedTerminalState = terminalSkillUpdates?.session_skill;
|
|
8321
|
+
const terminalModeState = journal?.terminal_mode_updates;
|
|
8322
|
+
const terminalTimestamp = journal?.terminal_timestamp;
|
|
8323
|
+
if (!expectedTerminalState || typeof expectedTerminalState !== "object" || Array.isArray(expectedTerminalState)) {
|
|
8324
|
+
throw new Error("Refusing cancellation without a canonical detached Advisory session skill payload.");
|
|
8325
|
+
}
|
|
8326
|
+
if (!terminalModeState || typeof terminalModeState !== "object" || Array.isArray(terminalModeState)
|
|
8327
|
+
|| typeof terminalTimestamp !== "string" || terminalTimestamp.length === 0) {
|
|
8328
|
+
throw new Error("Refusing cancellation without canonical detached Advisory terminal metadata.");
|
|
8329
|
+
}
|
|
8330
|
+
validateDetachedAdvisorySessionSkillTransition({
|
|
8331
|
+
baseline: JSON.parse(detachedSessionSkillBaseline ?? frozen.content),
|
|
8332
|
+
rootBaseline: detachedRootSkillBaseline
|
|
8333
|
+
? JSON.parse(detachedRootSkillBaseline)
|
|
8334
|
+
: null,
|
|
8335
|
+
current: JSON.parse(snapshot.content),
|
|
8336
|
+
journalProjection: expectedTerminalState,
|
|
8337
|
+
terminalModeState: {
|
|
8338
|
+
...activeAdvisory.state,
|
|
8339
|
+
...terminalModeState,
|
|
8340
|
+
},
|
|
8341
|
+
terminalTimestamp,
|
|
8342
|
+
sessionId: currentSessionId,
|
|
8343
|
+
});
|
|
8344
|
+
}
|
|
8345
|
+
runSelection.protectedFiles.set(path, snapshot);
|
|
8346
|
+
}
|
|
8347
|
+
}
|
|
8348
|
+
if (checkpoint === "mode_commit_ralplan.session-skill-write"
|
|
8349
|
+
|| checkpoint === "mirror_session_skill") {
|
|
8350
|
+
for (const path of runSelection.protectedFiles.keys()) {
|
|
8351
|
+
if (basename(path) === "skill-active-state.json"
|
|
8352
|
+
&& path.split(sep).includes("sessions")
|
|
8353
|
+
&& path.split(sep).includes(currentSessionId)) {
|
|
8354
|
+
pendingProtectedMutations.add(path);
|
|
8355
|
+
}
|
|
8356
|
+
}
|
|
8357
|
+
}
|
|
8358
|
+
if (runSelection.advisoryGenerationDir) {
|
|
8359
|
+
for (const name of ["fence.json", "closeout-journal.json"]) {
|
|
8360
|
+
const path = join(runSelection.advisoryGenerationDir, name);
|
|
8361
|
+
if (runSelection.protectedFiles.has(path) || !pendingProtectedMutations.has(path))
|
|
8362
|
+
continue;
|
|
8363
|
+
try {
|
|
8364
|
+
runSelection.protectedFiles.set(path, await readDetachedPinnedFile(path));
|
|
8365
|
+
pendingProtectedMutations.delete(path);
|
|
8366
|
+
}
|
|
8367
|
+
catch (error) {
|
|
8368
|
+
if (error.code !== "ENOENT")
|
|
8369
|
+
throw error;
|
|
8370
|
+
}
|
|
8371
|
+
}
|
|
8372
|
+
}
|
|
8373
|
+
const modeSnapshot = await readDetachedPinnedFile(activeAdvisory.path);
|
|
8374
|
+
const content = modeSnapshot.content;
|
|
8375
|
+
if (!expectedModeIdentity)
|
|
8376
|
+
throw new Error("Refusing cancellation without a pinned detached Ralplan state.");
|
|
8377
|
+
if (sessionModeMutationPending) {
|
|
8378
|
+
const stillOriginal = detachedIdentityMatches(modeSnapshot.identity, expectedModeIdentity)
|
|
8379
|
+
&& content === expectedModeContent;
|
|
8380
|
+
if (!stillOriginal) {
|
|
8381
|
+
const parsed = JSON.parse(content);
|
|
8382
|
+
if (parsed.mode !== "ralplan" || parsed.workflow_variant !== "advisory"
|
|
8383
|
+
|| parsed.advisory_generation_id !== activeAdvisory.state.advisory_generation_id
|
|
8384
|
+
|| parsed.session_id !== currentSessionId || parsed.active !== false) {
|
|
8385
|
+
throw new Error("Refusing cancellation because detached Advisory mode mutation was not canonical.");
|
|
8386
|
+
}
|
|
8387
|
+
expectedModeIdentity = modeSnapshot.identity;
|
|
8388
|
+
expectedModeContent = content;
|
|
8389
|
+
sessionModeMutationPending = false;
|
|
8390
|
+
}
|
|
8391
|
+
}
|
|
8392
|
+
else if (!detachedIdentityMatches(modeSnapshot.identity, expectedModeIdentity) || content !== expectedModeContent) {
|
|
8393
|
+
throw new Error(`Refusing cancellation because detached Ralplan state changed at ${checkpoint}.`);
|
|
8394
|
+
}
|
|
8395
|
+
if (runSelection.advisoryGenerationDir) {
|
|
8396
|
+
const fencePath = join(runSelection.advisoryGenerationDir, "fence.json");
|
|
8397
|
+
const journalPath = join(runSelection.advisoryGenerationDir, "closeout-journal.json");
|
|
8398
|
+
if (["fence_create", "fence_recovery_required", "fence_terminal", "admin_fence_event"].includes(checkpoint)) {
|
|
8399
|
+
pendingProtectedMutations.add(fencePath);
|
|
8400
|
+
}
|
|
8401
|
+
if (checkpoint === "journal_prepare" || checkpoint.startsWith("journal_step_")
|
|
8402
|
+
|| checkpoint === "journal_commit" || checkpoint === "journal_fence_terminal") {
|
|
8403
|
+
pendingProtectedMutations.add(journalPath);
|
|
8404
|
+
}
|
|
8405
|
+
}
|
|
8406
|
+
}
|
|
8407
|
+
: undefined;
|
|
8408
|
+
const cancelled = await cancelRalplanConsensus(cancellationCwd, currentSessionId, {
|
|
8409
|
+
revalidateAuthority: revalidateDetachedAdvisoryAuthority,
|
|
8410
|
+
});
|
|
8411
|
+
if (!cancelled)
|
|
8412
|
+
throw new Error("Refusing false Advisory cancellation success: the selected generation was not terminalized.");
|
|
8413
|
+
console.log("Cancelled: ralplan");
|
|
8414
|
+
return;
|
|
8415
|
+
}
|
|
6747
8416
|
const changed = new Set();
|
|
6748
8417
|
const reported = new Set();
|
|
6749
8418
|
const cancelMode = (mode, phase = "cancelled", reportIfWasActive = true) => {
|
|
@@ -6849,11 +8518,6 @@ async function cancelModes(args = [], options = {}) {
|
|
|
6849
8518
|
cancelMode(mode, "cancelled", true);
|
|
6850
8519
|
}
|
|
6851
8520
|
}
|
|
6852
|
-
const assertRunAuthority = () => {
|
|
6853
|
-
if (runSelection && !hasMatchingMadmaxDetachedRuntimeBinding(runSelection.record)) {
|
|
6854
|
-
throw new Error("Refusing cancellation because detached run authority changed.");
|
|
6855
|
-
}
|
|
6856
|
-
};
|
|
6857
8521
|
if (force && currentSessionId) {
|
|
6858
8522
|
const stopStateEntries = [...states.entries()].filter(([mode]) => mode === "native-stop");
|
|
6859
8523
|
for (const [, entry] of stopStateEntries) {
|
|
@@ -6888,7 +8552,8 @@ async function cancelModes(args = [], options = {}) {
|
|
|
6888
8552
|
const handle = await open(change.entry.path, fsConstants.O_RDWR | fsConstants.O_NOFOLLOW);
|
|
6889
8553
|
const currentStat = await handle.stat();
|
|
6890
8554
|
const currentContent = await handle.readFile({ encoding: "utf-8" });
|
|
6891
|
-
if (!currentStat.isFile()
|
|
8555
|
+
if (!currentStat.isFile()
|
|
8556
|
+
|| !cancellationFileIdentityMatches(change.entry, currentStat)) {
|
|
6892
8557
|
await handle.close();
|
|
6893
8558
|
throw new Error(`Refusing cancellation because state identity changed: ${change.entry.path}.`);
|
|
6894
8559
|
}
|