oh-my-codex 0.20.2 → 0.20.3
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.lock +7 -6
- package/Cargo.toml +1 -1
- package/README.md +26 -0
- package/crates/omx-runtime/Cargo.toml +1 -0
- package/crates/omx-runtime/src/main.rs +25 -2
- package/crates/omx-runtime/tests/execution.rs +219 -0
- package/crates/omx-runtime-core/src/dispatch.rs +96 -10
- package/crates/omx-runtime-core/src/engine.rs +839 -29
- package/crates/omx-runtime-core/src/lib.rs +8 -0
- package/dist/agents/__tests__/definitions.test.js +81 -0
- package/dist/agents/__tests__/definitions.test.js.map +1 -1
- package/dist/agents/__tests__/native-config.test.js +25 -0
- package/dist/agents/__tests__/native-config.test.js.map +1 -1
- package/dist/agents/native-config.d.ts +2 -1
- package/dist/agents/native-config.d.ts.map +1 -1
- package/dist/agents/native-config.js.map +1 -1
- package/dist/auth/hotswap.d.ts +6 -0
- package/dist/auth/hotswap.d.ts.map +1 -1
- package/dist/auth/hotswap.js +85 -15
- package/dist/auth/hotswap.js.map +1 -1
- package/dist/autopilot/__tests__/ralplan-gate.test.js +1 -1
- package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
- package/dist/cli/__tests__/auth.test.js +60 -6
- package/dist/cli/__tests__/auth.test.js.map +1 -1
- package/dist/cli/__tests__/codex-plugin-layout.test.js +80 -0
- package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js +103 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/exec.test.js +136 -0
- package/dist/cli/__tests__/exec.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +258 -4
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/launch-fallback.test.js +287 -1
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/mcp-parity.test.js +4 -1
- package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
- package/dist/cli/__tests__/native-hook-claim-journal.test.js +84 -6
- package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -1
- package/dist/cli/__tests__/ralph-goal-mode-contract.test.js +3 -3
- package/dist/cli/__tests__/ralph-goal-mode-contract.test.js.map +1 -1
- package/dist/cli/__tests__/ralph.test.js +3 -3
- package/dist/cli/__tests__/ralph.test.js.map +1 -1
- package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +56 -171
- package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
- package/dist/cli/__tests__/ralplan.test.js +37 -111
- package/dist/cli/__tests__/ralplan.test.js.map +1 -1
- package/dist/cli/__tests__/resume.test.js +11 -10
- package/dist/cli/__tests__/resume.test.js.map +1 -1
- package/dist/cli/__tests__/session-search-help.test.js +25 -1
- package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
- package/dist/cli/__tests__/setup-install-mode.test.js +42 -4
- package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
- package/dist/cli/__tests__/team.test.js +105 -7
- package/dist/cli/__tests__/team.test.js.map +1 -1
- package/dist/cli/__tests__/uninstall.test.js +46 -0
- package/dist/cli/__tests__/uninstall.test.js.map +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js +2 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
- package/dist/cli/doctor.d.ts +3 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +15 -2
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +138 -38
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp-parity.js +2 -2
- package/dist/cli/mcp-parity.js.map +1 -1
- package/dist/cli/native-hook-claim-journal.d.ts +16 -5
- package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
- package/dist/cli/native-hook-claim-journal.js +42 -29
- package/dist/cli/native-hook-claim-journal.js.map +1 -1
- package/dist/cli/ralph.js +2 -2
- package/dist/cli/ralph.js.map +1 -1
- package/dist/cli/ralplan.d.ts +4 -8
- package/dist/cli/ralplan.d.ts.map +1 -1
- package/dist/cli/ralplan.js +19 -112
- package/dist/cli/ralplan.js.map +1 -1
- package/dist/cli/session-search.d.ts +5 -0
- package/dist/cli/session-search.d.ts.map +1 -1
- package/dist/cli/session-search.js +87 -0
- package/dist/cli/session-search.js.map +1 -1
- package/dist/cli/setup.d.ts +5 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +80 -31
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/uninstall.d.ts +9 -0
- package/dist/cli/uninstall.d.ts.map +1 -1
- package/dist/cli/uninstall.js +61 -19
- package/dist/cli/uninstall.js.map +1 -1
- package/dist/compat/__tests__/rust-runtime-compat.test.js +1 -1
- package/dist/config/__tests__/generator-idempotent.test.js +330 -1
- package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
- package/dist/config/__tests__/generator-notify.test.js +2 -2
- package/dist/config/__tests__/generator-notify.test.js.map +1 -1
- package/dist/config/__tests__/models.test.js +67 -3
- package/dist/config/__tests__/models.test.js.map +1 -1
- package/dist/config/generator.d.ts +4 -20
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +499 -119
- package/dist/config/generator.js.map +1 -1
- package/dist/config/models.d.ts +12 -2
- package/dist/config/models.d.ts.map +1 -1
- package/dist/config/models.js +15 -1
- package/dist/config/models.js.map +1 -1
- package/dist/hooks/__tests__/agents-overlay.test.js +2 -2
- package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
- package/dist/hooks/__tests__/keyword-detector.test.js +4 -4
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js +238 -543
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +38 -4
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +96 -9
- package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +531 -12
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +155 -23
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +482 -32
- package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js +68 -1
- package/dist/hooks/__tests__/notify-hook-tmux-heal.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js +7 -1
- package/dist/hooks/__tests__/notify-hook-tmux-scrollback.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +43 -4
- package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/session.test.js +348 -2
- package/dist/hooks/__tests__/session.test.js.map +1 -1
- package/dist/hooks/agents-overlay.js +1 -1
- package/dist/hooks/session.d.ts +26 -2
- package/dist/hooks/session.d.ts.map +1 -1
- package/dist/hooks/session.js +169 -13
- package/dist/hooks/session.js.map +1 -1
- package/dist/leader/__tests__/contract.test.js +57 -7
- package/dist/leader/__tests__/contract.test.js.map +1 -1
- package/dist/leader/contract.d.ts +16 -0
- package/dist/leader/contract.d.ts.map +1 -1
- package/dist/leader/contract.js +79 -4
- package/dist/leader/contract.js.map +1 -1
- package/dist/mcp/__tests__/code-intel-server.test.js +31 -3
- package/dist/mcp/__tests__/code-intel-server.test.js.map +1 -1
- package/dist/mcp/__tests__/wiki-server.test.js +18 -0
- package/dist/mcp/__tests__/wiki-server.test.js.map +1 -1
- package/dist/mcp/code-intel-server.d.ts.map +1 -1
- package/dist/mcp/code-intel-server.js +13 -3
- package/dist/mcp/code-intel-server.js.map +1 -1
- package/dist/mcp/wiki-server.d.ts +11 -0
- package/dist/mcp/wiki-server.d.ts.map +1 -1
- package/dist/mcp/wiki-server.js +9 -2
- package/dist/mcp/wiki-server.js.map +1 -1
- package/dist/modes/__tests__/base-tmux-pane.test.js +4 -4
- package/dist/modes/__tests__/base-tmux-pane.test.js.map +1 -1
- package/dist/notifications/temp-contract.d.ts.map +1 -1
- package/dist/notifications/temp-contract.js +4 -0
- package/dist/notifications/temp-contract.js.map +1 -1
- package/dist/pipeline/__tests__/orchestrator.test.js +4 -4
- package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
- package/dist/pipeline/__tests__/stages.test.js +8 -8
- package/dist/pipeline/__tests__/stages.test.js.map +1 -1
- package/dist/ralplan/__tests__/consensus-gate.test.js +134 -10
- package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
- package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts +2 -0
- package/dist/ralplan/__tests__/documented-leader-preflight.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/documented-leader-preflight.test.js +46 -0
- package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -0
- package/dist/ralplan/__tests__/runtime.test.js +8 -4
- package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
- package/dist/ralplan/consensus-gate.d.ts.map +1 -1
- package/dist/ralplan/consensus-gate.js +58 -50
- package/dist/ralplan/consensus-gate.js.map +1 -1
- package/dist/ralplan/documented-leader-preflight.d.ts +34 -0
- package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -0
- package/dist/ralplan/documented-leader-preflight.js +58 -0
- package/dist/ralplan/documented-leader-preflight.js.map +1 -0
- package/dist/ralplan/runtime.d.ts +1 -0
- package/dist/ralplan/runtime.d.ts.map +1 -1
- package/dist/ralplan/runtime.js +11 -7
- package/dist/ralplan/runtime.js.map +1 -1
- package/dist/runtime/__tests__/bridge.test.js +42 -1
- package/dist/runtime/__tests__/bridge.test.js.map +1 -1
- package/dist/runtime/bridge.d.ts +12 -0
- package/dist/runtime/bridge.d.ts.map +1 -1
- package/dist/runtime/bridge.js +52 -1
- package/dist/runtime/bridge.js.map +1 -1
- package/dist/scripts/__tests__/codex-native-hook.test.js +2620 -487
- package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts +2 -0
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.js +108 -0
- package/dist/scripts/__tests__/reasoning-artifact-contract.test.js.map +1 -0
- package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +46 -173
- package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -1
- package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +39 -159
- package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
- package/dist/scripts/__tests__/smoke-packed-install.test.js +254 -2
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/codex-native-hook.d.ts +4 -1
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +11013 -1153
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/notify-fallback-watcher.js +82 -108
- package/dist/scripts/notify-fallback-watcher.js.map +1 -1
- package/dist/scripts/notify-hook/auto-nudge.d.ts.map +1 -1
- package/dist/scripts/notify-hook/auto-nudge.js +74 -3
- package/dist/scripts/notify-hook/auto-nudge.js.map +1 -1
- package/dist/scripts/notify-hook/team-dispatch.d.ts +9 -0
- package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-dispatch.js +234 -79
- package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.js +39 -13
- package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
- package/dist/scripts/notify-hook/team-tmux-guard.d.ts +12 -4
- package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-tmux-guard.js +365 -44
- package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.js +19 -18
- package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
- package/dist/scripts/notify-hook/team-worker.d.ts +3 -0
- package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-worker.js +37 -28
- package/dist/scripts/notify-hook/team-worker.js.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.js +28 -2
- package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
- package/dist/scripts/notify-hook.js +1 -0
- package/dist/scripts/notify-hook.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +48 -3
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +2796 -13
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/state/__tests__/mode-state-context.test.js +54 -0
- package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
- package/dist/state/__tests__/operations.test.js +2 -0
- package/dist/state/__tests__/operations.test.js.map +1 -1
- package/dist/state/mode-state-context.d.ts +5 -0
- package/dist/state/mode-state-context.d.ts.map +1 -1
- package/dist/state/mode-state-context.js +59 -0
- package/dist/state/mode-state-context.js.map +1 -1
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +24 -245
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
- package/dist/subagents/tracker.d.ts +1 -82
- package/dist/subagents/tracker.d.ts.map +1 -1
- package/dist/subagents/tracker.js +3 -275
- package/dist/subagents/tracker.js.map +1 -1
- package/dist/team/__tests__/api-interop.test.js +91 -1
- package/dist/team/__tests__/api-interop.test.js.map +1 -1
- package/dist/team/__tests__/delivery-e2e-smoke.test.js +52 -10
- package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
- package/dist/team/__tests__/idle-nudge.test.js +45 -15
- package/dist/team/__tests__/idle-nudge.test.js.map +1 -1
- package/dist/team/__tests__/mcp-comm.test.js +93 -27
- package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
- package/dist/team/__tests__/model-contract.test.js +110 -3
- package/dist/team/__tests__/model-contract.test.js.map +1 -1
- package/dist/team/__tests__/runtime-cli.test.js +9 -0
- package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +3204 -335
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/scaling.test.js +2359 -142
- package/dist/team/__tests__/scaling.test.js.map +1 -1
- package/dist/team/__tests__/state.test.js +804 -36
- package/dist/team/__tests__/state.test.js.map +1 -1
- package/dist/team/__tests__/team-ops-contract.test.js +6 -0
- package/dist/team/__tests__/team-ops-contract.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +3049 -784
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/__tests__/worker-runtime-identity.test.js +38 -2
- package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -1
- package/dist/team/__tests__/worktree.test.js +6 -0
- package/dist/team/__tests__/worktree.test.js.map +1 -1
- package/dist/team/api-interop.d.ts.map +1 -1
- package/dist/team/api-interop.js +15 -9
- package/dist/team/api-interop.js.map +1 -1
- package/dist/team/exact-pane.d.ts +23 -0
- package/dist/team/exact-pane.d.ts.map +1 -0
- package/dist/team/exact-pane.js +133 -0
- package/dist/team/exact-pane.js.map +1 -0
- package/dist/team/idle-nudge.d.ts +12 -1
- package/dist/team/idle-nudge.d.ts.map +1 -1
- package/dist/team/idle-nudge.js +41 -14
- package/dist/team/idle-nudge.js.map +1 -1
- package/dist/team/model-contract.d.ts +2 -5
- package/dist/team/model-contract.d.ts.map +1 -1
- package/dist/team/model-contract.js +59 -17
- package/dist/team/model-contract.js.map +1 -1
- package/dist/team/runtime.d.ts +1 -0
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +1244 -180
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/scaling.d.ts +13 -1
- package/dist/team/scaling.d.ts.map +1 -1
- package/dist/team/scaling.js +1430 -384
- package/dist/team/scaling.js.map +1 -1
- package/dist/team/state/dispatch.d.ts.map +1 -1
- package/dist/team/state/dispatch.js +26 -8
- package/dist/team/state/dispatch.js.map +1 -1
- package/dist/team/state/locks.d.ts.map +1 -1
- package/dist/team/state/locks.js +61 -13
- package/dist/team/state/locks.js.map +1 -1
- package/dist/team/state/tasks.d.ts.map +1 -1
- package/dist/team/state/tasks.js +4 -3
- package/dist/team/state/tasks.js.map +1 -1
- package/dist/team/state.d.ts +67 -1
- package/dist/team/state.d.ts.map +1 -1
- package/dist/team/state.js +636 -129
- package/dist/team/state.js.map +1 -1
- package/dist/team/team-ops.d.ts +5 -0
- package/dist/team/team-ops.d.ts.map +1 -1
- package/dist/team/team-ops.js +4 -0
- package/dist/team/team-ops.js.map +1 -1
- package/dist/team/tmux-session.d.ts +123 -23
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +1250 -291
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/team/worktree.d.ts.map +1 -1
- package/dist/team/worktree.js +4 -0
- package/dist/team/worktree.js.map +1 -1
- package/dist/utils/__tests__/file-durability.test.d.ts +2 -0
- package/dist/utils/__tests__/file-durability.test.d.ts.map +1 -0
- package/dist/utils/__tests__/file-durability.test.js +57 -0
- package/dist/utils/__tests__/file-durability.test.js.map +1 -0
- package/dist/utils/file-durability.d.ts +15 -0
- package/dist/utils/file-durability.d.ts.map +1 -0
- package/dist/utils/file-durability.js +37 -0
- package/dist/utils/file-durability.js.map +1 -0
- package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js +3 -0
- package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
- package/dist/wiki/lint.d.ts +4 -1
- package/dist/wiki/lint.d.ts.map +1 -1
- package/dist/wiki/lint.js +3 -2
- package/dist/wiki/lint.js.map +1 -1
- package/package.json +1 -1
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +24 -0
- package/plugins/oh-my-codex/skills/ralph/SKILL.md +6 -4
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +3 -2
- package/plugins/oh-my-codex/skills/team/SKILL.md +13 -9
- package/skills/ralph/SKILL.md +6 -4
- package/skills/ralplan/SKILL.md +3 -2
- package/skills/team/SKILL.md +13 -9
- package/src/scripts/__tests__/codex-native-hook.test.ts +2826 -497
- package/src/scripts/__tests__/reasoning-artifact-contract.test.ts +129 -0
- package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +48 -185
- package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +44 -177
- package/src/scripts/__tests__/smoke-packed-install.test.ts +320 -2
- package/src/scripts/codex-native-hook.ts +10715 -1069
- package/src/scripts/notify-fallback-watcher.ts +84 -115
- package/src/scripts/notify-hook/auto-nudge.ts +71 -3
- package/src/scripts/notify-hook/team-dispatch.ts +242 -75
- package/src/scripts/notify-hook/team-leader-nudge.ts +40 -18
- package/src/scripts/notify-hook/team-tmux-guard.ts +366 -39
- package/src/scripts/notify-hook/team-worker-stop.ts +20 -16
- package/src/scripts/notify-hook/team-worker.ts +39 -26
- package/src/scripts/notify-hook/tmux-injection.ts +28 -2
- package/src/scripts/notify-hook.ts +1 -0
- package/src/scripts/smoke-packed-install.ts +3196 -14
- package/dist/subagents/__tests__/adapted-role-binding.test.d.ts +0 -2
- package/dist/subagents/__tests__/adapted-role-binding.test.d.ts.map +0 -1
- package/dist/subagents/__tests__/adapted-role-binding.test.js +0 -988
- package/dist/subagents/__tests__/adapted-role-binding.test.js.map +0 -1
- package/dist/subagents/adapted-role-binding.d.ts +0 -17
- package/dist/subagents/adapted-role-binding.d.ts.map +0 -1
- package/dist/subagents/adapted-role-binding.js +0 -75
- package/dist/subagents/adapted-role-binding.js.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawnSync, execFile } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
|
|
3
|
+
import { closeSync, chmodSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
|
-
import { dirname, isAbsolute, join, resolve } from 'path';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
6
6
|
import { CODEX_BYPASS_FLAG, CLAUDE_SKIP_PERMISSIONS_FLAG, MADMAX_FLAG, CONFIG_FLAG, LONG_CONFIG_FLAG, MODEL_FLAG, } from '../cli/constants.js';
|
|
7
7
|
import { getAgent } from '../agents/definitions.js';
|
|
8
8
|
import { buildCapturePaneArgv as sharedBuildCapturePaneArgv, buildVisibleCapturePaneArgv as sharedBuildVisibleCapturePaneArgv, normalizeTmuxCapture as sharedNormalizeTmuxCapture, paneHasActiveTask as sharedPaneHasActiveTask, paneIsBootstrapping as sharedPaneIsBootstrapping, paneShowsCodexViewport as sharedPaneShowsCodexViewport, paneLooksReady as sharedPaneLooksReady, } from '../scripts/tmux-hook-engine.js';
|
|
@@ -11,6 +11,8 @@ import { classifyTeamWorkerLaunchPolicy, extractModelProviderOverrideValue, norm
|
|
|
11
11
|
import { sleep, sleepSync } from '../utils/sleep.js';
|
|
12
12
|
import { buildPlatformCommandSpec, classifySpawnError, resolveCommandPathForPlatform, spawnPlatformCommandSync, } from '../utils/platform-command.js';
|
|
13
13
|
import { resolveOmxCliEntryPath } from '../utils/paths.js';
|
|
14
|
+
import { readExactPaneProof, readExactPaneProofsSync, readExactPaneProofSync } from './exact-pane.js';
|
|
15
|
+
import { resolveCanonicalTeamStateRoot } from './state-root.js';
|
|
14
16
|
const execFileAsync = promisify(execFile);
|
|
15
17
|
import { HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES } from '../hud/constants.js';
|
|
16
18
|
import { OMX_TMUX_HUD_OWNER_ENV } from '../hud/reconcile.js';
|
|
@@ -18,6 +20,41 @@ import { findHudWatchPaneIds, hudPaneMatchesOwner, OMX_TMUX_HUD_LEADER_PANE_ENV
|
|
|
18
20
|
const OMX_INSTANCE_OPTION = '@omx_instance_id';
|
|
19
21
|
const OMX_PANE_INSTANCE_OPTION = '@omx_pane_instance_id';
|
|
20
22
|
const OMX_TEAM_PANE_OWNER_OPTION = '@omx_team_pane_owner_id';
|
|
23
|
+
/** `kill(pid, 0)` establishes absence only on ESRCH. */
|
|
24
|
+
function probeProcessLiveness(pid) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return 'alive';
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return error.code === 'ESRCH' ? 'gone' : 'unknown';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Carries live tmux resources when creation cannot safely roll them back. */
|
|
34
|
+
export class CreateTeamSessionPartialError extends Error {
|
|
35
|
+
partialSession;
|
|
36
|
+
proofUnavailable;
|
|
37
|
+
originalError;
|
|
38
|
+
cleanupErrors;
|
|
39
|
+
constructor(partialSession, proofUnavailable, originalError,
|
|
40
|
+
/** Cleanup commands that failed after resources were created and must be retried. */
|
|
41
|
+
cleanupErrors = []) {
|
|
42
|
+
super('create_team_session_cleanup_incomplete');
|
|
43
|
+
this.partialSession = partialSession;
|
|
44
|
+
this.proofUnavailable = proofUnavailable;
|
|
45
|
+
this.originalError = originalError;
|
|
46
|
+
this.cleanupErrors = cleanupErrors;
|
|
47
|
+
this.name = 'CreateTeamSessionPartialError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
class ExactPaneProofUnavailableError extends Error {
|
|
51
|
+
proof;
|
|
52
|
+
constructor(proof) {
|
|
53
|
+
super(`exact_pane_proof_unavailable:${proof.paneId}:${proof.reason}`);
|
|
54
|
+
this.proof = proof;
|
|
55
|
+
this.name = 'ExactPaneProofUnavailableError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
21
58
|
const INJECTION_MARKER = '[OMX_TMUX_INJECT]';
|
|
22
59
|
const MODEL_INSTRUCTIONS_FILE_KEY = 'model_instructions_file';
|
|
23
60
|
const OMX_BYPASS_DEFAULT_SYSTEM_PROMPT_ENV = 'OMX_BYPASS_DEFAULT_SYSTEM_PROMPT';
|
|
@@ -74,6 +111,206 @@ function runTmux(args) {
|
|
|
74
111
|
}
|
|
75
112
|
return { ok: true, stdout: (result.stdout || '').trim() };
|
|
76
113
|
}
|
|
114
|
+
/** Preserve structured tmux fields, including an empty final field. */
|
|
115
|
+
/** Preserve structured tmux fields, including an empty final field. */
|
|
116
|
+
function runTmuxStructured(args) {
|
|
117
|
+
const { result } = spawnPlatformCommandSync('tmux', args, { encoding: 'utf-8' });
|
|
118
|
+
if (result.error)
|
|
119
|
+
return { ok: false, stderr: result.error.message };
|
|
120
|
+
if (result.status !== 0) {
|
|
121
|
+
return { ok: false, stderr: (result.stderr || '').trim() || `tmux exited ${result.status}` };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, stdout: (result.stdout || '').replace(/\r?\n$/, '') };
|
|
124
|
+
}
|
|
125
|
+
function restoredHudCleanupDebtPath(cwd, stateRoot) {
|
|
126
|
+
const canonicalStateRoot = resolveCanonicalTeamStateRoot(resolve(cwd));
|
|
127
|
+
if (typeof stateRoot !== 'string' || !stateRoot.trim()) {
|
|
128
|
+
return join(canonicalStateRoot, '.restored-hud-cleanup-debt.json');
|
|
129
|
+
}
|
|
130
|
+
const canonicalTeamsRoot = join(canonicalStateRoot, 'team');
|
|
131
|
+
const root = resolve(stateRoot);
|
|
132
|
+
const relativeRoot = relative(canonicalTeamsRoot, root);
|
|
133
|
+
if (!isAbsolute(stateRoot.trim())
|
|
134
|
+
|| !relativeRoot
|
|
135
|
+
|| relativeRoot === '..'
|
|
136
|
+
|| relativeRoot.startsWith(`..${sep}`)
|
|
137
|
+
|| isAbsolute(relativeRoot)
|
|
138
|
+
|| relativeRoot.split(sep).length !== 1) {
|
|
139
|
+
throw new Error('restored_hud_cleanup_debt_state_root_invalid');
|
|
140
|
+
}
|
|
141
|
+
for (let candidate = root;; candidate = dirname(candidate)) {
|
|
142
|
+
try {
|
|
143
|
+
const metadata = lstatSync(candidate);
|
|
144
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
145
|
+
throw new Error('restored_hud_cleanup_debt_state_root_invalid');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code !== 'ENOENT')
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
if (candidate === canonicalTeamsRoot)
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return join(root, '.restored-hud-cleanup-debt.json');
|
|
156
|
+
}
|
|
157
|
+
function syncRestoredHudDebtParentSync(path) {
|
|
158
|
+
let descriptor;
|
|
159
|
+
try {
|
|
160
|
+
descriptor = openSync(dirname(path), 'r');
|
|
161
|
+
fsyncSync(descriptor);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
const code = error.code;
|
|
165
|
+
if (!(code === 'EPERM' && process.platform === 'win32'))
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (descriptor !== undefined)
|
|
170
|
+
closeSync(descriptor);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function persistRestoredHudCleanupDebtSync(cwd, debt, stateRoot) {
|
|
174
|
+
const debtPath = restoredHudCleanupDebtPath(cwd, stateRoot);
|
|
175
|
+
mkdirSync(dirname(debtPath), { recursive: true });
|
|
176
|
+
// Recheck after mkdir: an untrusted Team root must still be canonical and
|
|
177
|
+
// non-symlinked immediately before the durable file mutation.
|
|
178
|
+
restoredHudCleanupDebtPath(cwd, stateRoot);
|
|
179
|
+
const temporaryPath = `${debtPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
180
|
+
writeFileSync(temporaryPath, `${JSON.stringify(debt)}\n`, { mode: 0o600 });
|
|
181
|
+
const temporaryDescriptor = openSync(temporaryPath, 'r');
|
|
182
|
+
try {
|
|
183
|
+
fsyncSync(temporaryDescriptor);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
closeSync(temporaryDescriptor);
|
|
187
|
+
}
|
|
188
|
+
renameSync(temporaryPath, debtPath);
|
|
189
|
+
syncRestoredHudDebtParentSync(debtPath);
|
|
190
|
+
}
|
|
191
|
+
function parseRestoredHudCleanupDebtSync(cwd, stateRoot) {
|
|
192
|
+
const debtPath = restoredHudCleanupDebtPath(cwd, stateRoot);
|
|
193
|
+
if (!existsSync(debtPath))
|
|
194
|
+
return null;
|
|
195
|
+
let debt;
|
|
196
|
+
try {
|
|
197
|
+
debt = JSON.parse(readFileSync(debtPath, 'utf8'));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new Error('restored_hud_cleanup_debt_malformed');
|
|
201
|
+
}
|
|
202
|
+
if (debt.schema_version !== 1 || debt.operation !== 'restored_hud_cleanup'
|
|
203
|
+
|| !hasExplicitWorkerPaneId(debt.pane_id)
|
|
204
|
+
|| !hasExplicitWorkerPaneId(debt.leader_pane_id)
|
|
205
|
+
|| !Number.isSafeInteger(debt.leader_pane_pid) || debt.leader_pane_pid <= 0
|
|
206
|
+
|| !(typeof debt.leader_pane_owner_id === 'string' || debt.leader_pane_owner_id === null)
|
|
207
|
+
|| debt.hud_owner_leader_pane_id !== debt.leader_pane_id
|
|
208
|
+
|| (debt.pane_pid !== null && (!Number.isSafeInteger(debt.pane_pid) || debt.pane_pid <= 0))) {
|
|
209
|
+
throw new Error('restored_hud_cleanup_debt_malformed');
|
|
210
|
+
}
|
|
211
|
+
return { path: debtPath, debt };
|
|
212
|
+
}
|
|
213
|
+
function removeRestoredHudCleanupDebtSync(path) {
|
|
214
|
+
unlinkSync(path);
|
|
215
|
+
syncRestoredHudDebtParentSync(path);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Replays a prior restored-HUD obligation. A live pane is killable only when
|
|
219
|
+
* its recorded PID, HUD command identity, and tagged leader identity all
|
|
220
|
+
* still match; PID-less and ambiguous records remain durable debt.
|
|
221
|
+
*/
|
|
222
|
+
export function reconcileRestoredHudCleanupDebtSync(cwd, stateRoot) {
|
|
223
|
+
const record = parseRestoredHudCleanupDebtSync(cwd, stateRoot);
|
|
224
|
+
if (!record)
|
|
225
|
+
return;
|
|
226
|
+
const { path, debt } = record;
|
|
227
|
+
const proof = readExactPaneProofSync(debt.pane_id);
|
|
228
|
+
if (proof.status === 'gone') {
|
|
229
|
+
removeRestoredHudCleanupDebtSync(path);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (proof.status !== 'live' || debt.pane_pid === null || proof.pid !== debt.pane_pid || !debt.leader_pane_owner_id) {
|
|
233
|
+
throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
|
|
234
|
+
}
|
|
235
|
+
const leader = requireLiveTeamOwnedPaneSync(debt.leader_pane_id, debt.leader_pane_pid, debt.leader_pane_owner_id);
|
|
236
|
+
const topology = listPanesResult(leader);
|
|
237
|
+
const hudMatches = !topology.error && topology.panes.filter((pane) => pane.paneId === proof.paneId
|
|
238
|
+
&& hudPaneMatchesOwner(pane, { leaderPaneId: debt.hud_owner_leader_pane_id })).length === 1;
|
|
239
|
+
if (!hudMatches)
|
|
240
|
+
throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
|
|
241
|
+
killExactPaneSync(proof.paneId, debt.pane_pid, () => {
|
|
242
|
+
requireLiveTeamOwnedPaneSync(debt.leader_pane_id, debt.leader_pane_pid, debt.leader_pane_owner_id);
|
|
243
|
+
});
|
|
244
|
+
removeRestoredHudCleanupDebtSync(path);
|
|
245
|
+
}
|
|
246
|
+
/** Remove restored-HUD debt only after the canonical config transaction commits the same frozen identity. */
|
|
247
|
+
export function finalizeRestoredHudCleanupDebtSync(cwd, paneId, panePid, stateRoot, _requireFreshHudIdentity) {
|
|
248
|
+
const record = parseRestoredHudCleanupDebtSync(cwd, stateRoot);
|
|
249
|
+
if (!record)
|
|
250
|
+
return;
|
|
251
|
+
const { debt } = record;
|
|
252
|
+
if (debt.pane_id !== paneId || debt.pane_pid !== panePid || !debt.leader_pane_owner_id) {
|
|
253
|
+
throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
|
|
254
|
+
}
|
|
255
|
+
const leader = requireLiveTeamOwnedPaneSync(debt.leader_pane_id, debt.leader_pane_pid, debt.leader_pane_owner_id);
|
|
256
|
+
const topology = listPanesResult(leader);
|
|
257
|
+
const hudMatches = !topology.error && topology.panes.filter((pane) => pane.paneId === debt.pane_id
|
|
258
|
+
&& hudPaneMatchesOwner(pane, { leaderPaneId: debt.hud_owner_leader_pane_id })).length === 1;
|
|
259
|
+
if (!hudMatches)
|
|
260
|
+
throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
|
|
261
|
+
const finalProof = readExactPaneProofSync(debt.pane_id);
|
|
262
|
+
if (finalProof.status !== 'live' || finalProof.pid !== debt.pane_pid) {
|
|
263
|
+
throw new Error(`restored_hud_cleanup_debt_unresolved:${debt.pane_id}`);
|
|
264
|
+
}
|
|
265
|
+
removeRestoredHudCleanupDebtSync(record.path);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Kill only a pane that a fresh global snapshot proves live. Callers treat
|
|
269
|
+
* gone/dead rows as already cleaned and unavailable snapshots as fail-closed.
|
|
270
|
+
*/
|
|
271
|
+
function requireLiveExactPaneSync(paneId, expectedPid) {
|
|
272
|
+
const proof = readExactPaneProofSync(paneId);
|
|
273
|
+
if (proof.status === 'unavailable')
|
|
274
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
275
|
+
if (proof.status === 'gone')
|
|
276
|
+
throw new Error(`tmux pane is not proven live: ${paneId}`);
|
|
277
|
+
if (expectedPid !== undefined && proof.pid !== expectedPid) {
|
|
278
|
+
throw new Error(`tmux pane identity changed: ${paneId}`);
|
|
279
|
+
}
|
|
280
|
+
return proof.paneId;
|
|
281
|
+
}
|
|
282
|
+
function killExactPaneSync(paneId, expectedPid, assertAuthorization) {
|
|
283
|
+
const proof = readExactPaneProofSync(paneId);
|
|
284
|
+
if (proof.status === 'unavailable')
|
|
285
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
286
|
+
if (proof.status === 'gone')
|
|
287
|
+
return;
|
|
288
|
+
if (expectedPid !== undefined && proof.pid !== expectedPid) {
|
|
289
|
+
throw new Error(`tmux pane identity changed: ${paneId}`);
|
|
290
|
+
}
|
|
291
|
+
assertAuthorization?.();
|
|
292
|
+
// Authorization can read tmux. Re-prove immediately after it so a recycled
|
|
293
|
+
// pane ID cannot be targeted by the subsequent kill.
|
|
294
|
+
const finalProof = readExactPaneProofSync(proof.paneId);
|
|
295
|
+
if (finalProof.status === 'unavailable')
|
|
296
|
+
throw new ExactPaneProofUnavailableError(finalProof);
|
|
297
|
+
if (finalProof.status === 'gone')
|
|
298
|
+
return;
|
|
299
|
+
if (finalProof.pid !== proof.pid || (expectedPid !== undefined && finalProof.pid !== expectedPid)) {
|
|
300
|
+
throw new Error(`tmux pane identity changed: ${paneId}`);
|
|
301
|
+
}
|
|
302
|
+
const result = runTmux(['kill-pane', '-t', finalProof.paneId]);
|
|
303
|
+
if (!result.ok)
|
|
304
|
+
throw new Error(`failed to kill tmux pane ${finalProof.paneId}: ${result.stderr}`);
|
|
305
|
+
const afterKill = readExactPaneProofSync(finalProof.paneId);
|
|
306
|
+
if (afterKill.status === 'unavailable')
|
|
307
|
+
throw new ExactPaneProofUnavailableError(afterKill);
|
|
308
|
+
if (afterKill.status !== 'gone') {
|
|
309
|
+
if (afterKill.pid !== finalProof.pid)
|
|
310
|
+
throw new Error(`tmux pane identity changed: ${paneId}`);
|
|
311
|
+
throw new Error(`tmux pane remains live after kill: ${paneId}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
77
314
|
function appendNoUnderlineStyleFlags(style) {
|
|
78
315
|
const normalized = style
|
|
79
316
|
.split(/[,\s]+/)
|
|
@@ -86,7 +323,8 @@ function appendNoUnderlineStyleFlags(style) {
|
|
|
86
323
|
}
|
|
87
324
|
return combined.join(',');
|
|
88
325
|
}
|
|
89
|
-
function sanitizeTmuxStyleOption(sessionTarget, optionName) {
|
|
326
|
+
function sanitizeTmuxStyleOption(sessionTarget, optionName, beforeEffect) {
|
|
327
|
+
beforeEffect?.();
|
|
90
328
|
const shown = runTmux(['show-options', '-gv', '-t', sessionTarget, optionName]);
|
|
91
329
|
if (!shown.ok)
|
|
92
330
|
return false;
|
|
@@ -96,36 +334,39 @@ function sanitizeTmuxStyleOption(sessionTarget, optionName) {
|
|
|
96
334
|
const sanitized = appendNoUnderlineStyleFlags(current);
|
|
97
335
|
if (sanitized === current)
|
|
98
336
|
return true;
|
|
337
|
+
beforeEffect?.();
|
|
99
338
|
const result = runTmux(['set-option', '-t', sessionTarget, optionName, sanitized]);
|
|
100
339
|
return result.ok;
|
|
101
340
|
}
|
|
102
|
-
function tagPaneInstance(paneTarget, instanceId) {
|
|
341
|
+
function tagPaneInstance(paneTarget, instanceId, expectedPanePid) {
|
|
103
342
|
const target = paneTarget.trim();
|
|
104
343
|
const sanitized = instanceId.trim();
|
|
105
344
|
if (!target || !sanitized)
|
|
106
345
|
return;
|
|
107
|
-
const
|
|
346
|
+
const provenTarget = requireLiveExactPaneSync(target, expectedPanePid);
|
|
347
|
+
const result = runTmux(['set-option', '-p', '-t', provenTarget, OMX_PANE_INSTANCE_OPTION, sanitized]);
|
|
108
348
|
if (!result.ok) {
|
|
109
|
-
throw new Error(`failed to tag tmux pane ${
|
|
349
|
+
throw new Error(`failed to tag tmux pane ${provenTarget}: ${result.stderr}`);
|
|
110
350
|
}
|
|
111
351
|
}
|
|
112
|
-
export function tagPaneTeamOwner(paneTarget, teamOwnerId) {
|
|
352
|
+
export function tagPaneTeamOwner(paneTarget, teamOwnerId, expectedPanePid) {
|
|
113
353
|
const target = paneTarget.trim();
|
|
114
354
|
const sanitized = teamOwnerId.trim();
|
|
115
355
|
if (!target || !sanitized)
|
|
116
356
|
return;
|
|
117
|
-
const
|
|
357
|
+
const provenTarget = requireLiveExactPaneSync(target, expectedPanePid);
|
|
358
|
+
const result = runTmux(['set-option', '-p', '-t', provenTarget, OMX_TEAM_PANE_OWNER_OPTION, sanitized]);
|
|
118
359
|
if (!result.ok) {
|
|
119
|
-
throw new Error(`failed to tag tmux pane ${
|
|
360
|
+
throw new Error(`failed to tag tmux pane ${provenTarget}: ${result.stderr}`);
|
|
120
361
|
}
|
|
121
362
|
}
|
|
122
|
-
export function mitigateCopyModeUnderlineArtifacts(sessionTarget) {
|
|
363
|
+
export function mitigateCopyModeUnderlineArtifacts(sessionTarget, beforeEffect) {
|
|
123
364
|
const normalizedTarget = sessionTarget.trim();
|
|
124
365
|
if (normalizedTarget === '')
|
|
125
366
|
return false;
|
|
126
367
|
let applied = false;
|
|
127
368
|
for (const optionName of TMUX_COPY_MODE_STYLE_OPTIONS) {
|
|
128
|
-
if (sanitizeTmuxStyleOption(normalizedTarget, optionName)) {
|
|
369
|
+
if (sanitizeTmuxStyleOption(normalizedTarget, optionName, beforeEffect)) {
|
|
129
370
|
applied = true;
|
|
130
371
|
}
|
|
131
372
|
}
|
|
@@ -181,18 +422,97 @@ function baseSessionName(target) {
|
|
|
181
422
|
return target.split(':')[0] || target;
|
|
182
423
|
}
|
|
183
424
|
function listPanes(target) {
|
|
184
|
-
|
|
425
|
+
return listPanesResult(target).panes;
|
|
426
|
+
}
|
|
427
|
+
function listPanesResult(target) {
|
|
428
|
+
const result = runTmuxStructured(['list-panes', '-t', target, '-F', '#{pane_id}\t#{pane_current_command}\t#{pane_start_command}']);
|
|
185
429
|
if (!result.ok)
|
|
186
|
-
return [];
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
430
|
+
return { panes: [], error: result.stderr };
|
|
431
|
+
const panes = [];
|
|
432
|
+
for (const rawLine of result.stdout.split('\n')) {
|
|
433
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
434
|
+
if (line.length === 0)
|
|
435
|
+
continue;
|
|
436
|
+
const firstSeparator = line.indexOf('\t');
|
|
437
|
+
const secondSeparator = firstSeparator >= 0 ? line.indexOf('\t', firstSeparator + 1) : -1;
|
|
438
|
+
const paneId = firstSeparator >= 0 ? line.slice(0, firstSeparator) : line;
|
|
439
|
+
if (firstSeparator < 0 || secondSeparator < 0 || !/^%[0-9]+$/.test(paneId)) {
|
|
440
|
+
return { panes: [], error: 'malformed pane topology' };
|
|
441
|
+
}
|
|
442
|
+
if (panes.some((pane) => pane.paneId === paneId)) {
|
|
443
|
+
return { panes: [], error: 'malformed pane topology' };
|
|
444
|
+
}
|
|
445
|
+
panes.push({
|
|
446
|
+
paneId,
|
|
447
|
+
currentCommand: line.slice(firstSeparator + 1, secondSeparator),
|
|
448
|
+
startCommand: line.slice(secondSeparator + 1),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
return { panes, error: null };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Window-wide layout effects may only touch the exact startup pane set. A
|
|
455
|
+
* target-scoped topology read rejects foreign/new panes, and targeted global
|
|
456
|
+
* snapshots pin every surviving pane identity. The final scoped read happens
|
|
457
|
+
* immediately before the mutation so panes added during owner reads cannot
|
|
458
|
+
* inherit that authority.
|
|
459
|
+
*/
|
|
460
|
+
/**
|
|
461
|
+
* Re-prove an explicitly Team-owned pane immediately before an effect. The
|
|
462
|
+
* double PID proof keeps ownership and process identity continuous across an
|
|
463
|
+
* untrusted tmux option read.
|
|
464
|
+
*/
|
|
465
|
+
function requireLiveTeamOwnedPaneSync(paneId, expectedPanePid, expectedTeamOwnerId) {
|
|
466
|
+
const target = requireLiveExactPaneSync(paneId, expectedPanePid);
|
|
467
|
+
const owner = readPaneTeamOwnerTagResult(target);
|
|
468
|
+
if (owner.status !== 'value' || owner.value !== expectedTeamOwnerId) {
|
|
469
|
+
const detail = owner.status === 'error' ? owner.error : 'missing';
|
|
470
|
+
throw new Error(`tmux pane team owner changed: ${target}: ${detail}`);
|
|
471
|
+
}
|
|
472
|
+
return requireLiveExactPaneSync(target, expectedPanePid);
|
|
473
|
+
}
|
|
474
|
+
function requireFrozenWindowTopologySync(teamTarget, expectedPanePids, expectedPaneOwners) {
|
|
475
|
+
const topology = listPanesResult(teamTarget);
|
|
476
|
+
if (topology.error)
|
|
477
|
+
throw new Error(`failed to read tmux pane topology: ${topology.error}`);
|
|
478
|
+
const actualPaneIds = new Set(topology.panes.map((pane) => pane.paneId));
|
|
479
|
+
if (actualPaneIds.size !== expectedPanePids.size
|
|
480
|
+
|| [...expectedPanePids.keys()].some((paneId) => !actualPaneIds.has(paneId))) {
|
|
481
|
+
throw new Error('tmux window topology changed before layout mutation');
|
|
482
|
+
}
|
|
483
|
+
for (const [paneId, expectedPanePid] of expectedPanePids) {
|
|
484
|
+
const expectedOwner = expectedPaneOwners?.get(paneId);
|
|
485
|
+
if (expectedOwner)
|
|
486
|
+
requireLiveTeamOwnedPaneSync(paneId, expectedPanePid, expectedOwner);
|
|
487
|
+
else {
|
|
488
|
+
const proof = readExactPaneProofSync(paneId);
|
|
489
|
+
if (proof.status === 'unavailable')
|
|
490
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
491
|
+
if (proof.status === 'gone')
|
|
492
|
+
throw new Error(`tmux pane is not proven live: ${proof.paneId}`);
|
|
493
|
+
if (proof.pid !== expectedPanePid)
|
|
494
|
+
throw new Error(`tmux pane identity changed: ${proof.paneId}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const finalProofs = readExactPaneProofsSync([...expectedPanePids.keys()]);
|
|
498
|
+
for (const [index, [paneId, expectedPanePid]] of [...expectedPanePids.entries()].entries()) {
|
|
499
|
+
const proof = finalProofs[index];
|
|
500
|
+
if (proof.status === 'unavailable')
|
|
501
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
502
|
+
if (proof.status === 'gone')
|
|
503
|
+
throw new Error(`tmux pane is not proven live: ${proof.paneId}`);
|
|
504
|
+
if (proof.paneId !== paneId || proof.pid !== expectedPanePid) {
|
|
505
|
+
throw new Error(`tmux pane identity changed: ${paneId}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const finalTopology = listPanesResult(teamTarget);
|
|
509
|
+
if (finalTopology.error)
|
|
510
|
+
throw new Error(`failed to read tmux pane topology: ${finalTopology.error}`);
|
|
511
|
+
const finalPaneIds = new Set(finalTopology.panes.map((pane) => pane.paneId));
|
|
512
|
+
if (finalPaneIds.size !== expectedPanePids.size
|
|
513
|
+
|| [...expectedPanePids.keys()].some((paneId) => !finalPaneIds.has(paneId))) {
|
|
514
|
+
throw new Error('tmux window topology changed before layout mutation');
|
|
515
|
+
}
|
|
196
516
|
}
|
|
197
517
|
export function listPaneIds(target) {
|
|
198
518
|
return listPanes(target).map((pane) => pane.paneId);
|
|
@@ -237,12 +557,6 @@ export function chooseTeamLeaderPaneId(panes, preferredPaneId) {
|
|
|
237
557
|
return nonHud.paneId;
|
|
238
558
|
return preferredPaneId;
|
|
239
559
|
}
|
|
240
|
-
function findHudPaneIds(target, leaderPaneId) {
|
|
241
|
-
return findHudWatchPaneIds(listPanes(target), leaderPaneId, { leaderPaneId });
|
|
242
|
-
}
|
|
243
|
-
function findOwnedHudPaneIds(target, leaderPaneId) {
|
|
244
|
-
return findHudWatchPaneIds(listPanes(target), leaderPaneId, { leaderPaneId });
|
|
245
|
-
}
|
|
246
560
|
function readPaneCurrentPath(paneId) {
|
|
247
561
|
if (!paneId.startsWith('%'))
|
|
248
562
|
return null;
|
|
@@ -281,7 +595,8 @@ function shouldAttemptRestoreCwdCandidate(candidate) {
|
|
|
281
595
|
}
|
|
282
596
|
return pathIsUsableDirectory(candidate.rawPath);
|
|
283
597
|
}
|
|
284
|
-
function resolveStandaloneHudRestoreCwdCandidates(leaderPaneId, fallbackCwd, explicitCwd) {
|
|
598
|
+
function resolveStandaloneHudRestoreCwdCandidates(leaderPaneId, fallbackCwd, explicitCwd, beforeReadLiveLeaderCwd) {
|
|
599
|
+
beforeReadLiveLeaderCwd?.();
|
|
285
600
|
const liveLeaderCwd = readPaneCurrentPath(leaderPaneId);
|
|
286
601
|
return uniqueRestoreCwdCandidates([
|
|
287
602
|
{ source: 'explicit', rawPath: explicitCwd },
|
|
@@ -318,105 +633,161 @@ async function runTmuxAsync(args) {
|
|
|
318
633
|
return { ok: false, stderr: (err.stderr || err.message || '').trim() || 'tmux command failed' };
|
|
319
634
|
}
|
|
320
635
|
}
|
|
321
|
-
|
|
636
|
+
function hasExplicitWorkerPaneId(workerPaneId) {
|
|
637
|
+
return typeof workerPaneId === 'string' && workerPaneId.trim().length > 0;
|
|
638
|
+
}
|
|
639
|
+
function resolveWorkerPaneTargetSync(sessionName, workerIndex, workerPaneId) {
|
|
640
|
+
if (!hasExplicitWorkerPaneId(workerPaneId))
|
|
641
|
+
return paneTarget(sessionName, workerIndex);
|
|
642
|
+
const proof = readExactPaneProofSync(workerPaneId);
|
|
643
|
+
return proof.status === 'live' ? proof.paneId : null;
|
|
644
|
+
}
|
|
645
|
+
async function resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId) {
|
|
646
|
+
if (!hasExplicitWorkerPaneId(workerPaneId))
|
|
647
|
+
return paneTarget(sessionName, workerIndex);
|
|
648
|
+
const proof = await readExactPaneProof(workerPaneId);
|
|
649
|
+
return proof.status === 'live' ? proof.paneId : null;
|
|
650
|
+
}
|
|
651
|
+
function createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
652
|
+
if (!hasExplicitWorkerPaneId(workerPaneId)) {
|
|
653
|
+
return () => resolveWorkerPaneTargetSync(sessionName, workerIndex, workerPaneId);
|
|
654
|
+
}
|
|
655
|
+
const pinnedPid = expectedPanePid;
|
|
656
|
+
const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
|
|
657
|
+
const canonicalHudPaneId = typeof hudPaneId === 'string' ? hudPaneId.trim() : '';
|
|
658
|
+
return () => {
|
|
659
|
+
if (typeof pinnedPid !== 'number' || !Number.isSafeInteger(pinnedPid) || pinnedPid <= 0)
|
|
660
|
+
return null;
|
|
661
|
+
if (!expectedOwner)
|
|
662
|
+
return null;
|
|
663
|
+
if (canonicalHudPaneId && workerPaneId === canonicalHudPaneId) {
|
|
664
|
+
throw new Error(`tmux worker pane is HUD target: ${workerPaneId}`);
|
|
665
|
+
}
|
|
666
|
+
const proof = readExactPaneProofSync(workerPaneId);
|
|
667
|
+
if (proof.status !== 'live')
|
|
668
|
+
return null;
|
|
669
|
+
if (proof.pid !== pinnedPid) {
|
|
670
|
+
throw new Error(`tmux pane identity changed: ${workerPaneId}`);
|
|
671
|
+
}
|
|
672
|
+
const owner = readPaneTeamOwnerTagResult(proof.paneId);
|
|
673
|
+
if (owner.status !== 'value' || owner.value !== expectedOwner)
|
|
674
|
+
return null;
|
|
675
|
+
const finalProof = readExactPaneProofSync(workerPaneId);
|
|
676
|
+
if (finalProof.status !== 'live')
|
|
677
|
+
return null;
|
|
678
|
+
if (finalProof.pid !== pinnedPid) {
|
|
679
|
+
throw new Error(`tmux pane identity changed: ${workerPaneId}`);
|
|
680
|
+
}
|
|
681
|
+
return finalProof.paneId;
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
685
|
+
if (!hasExplicitWorkerPaneId(workerPaneId)) {
|
|
686
|
+
return () => resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId);
|
|
687
|
+
}
|
|
688
|
+
const pinnedPid = expectedPanePid;
|
|
689
|
+
const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
|
|
690
|
+
const canonicalHudPaneId = typeof hudPaneId === 'string' ? hudPaneId.trim() : '';
|
|
691
|
+
let lastResolvedPid;
|
|
692
|
+
const resolveTarget = async () => {
|
|
693
|
+
if (typeof pinnedPid !== 'number' || !Number.isSafeInteger(pinnedPid) || pinnedPid <= 0)
|
|
694
|
+
return null;
|
|
695
|
+
if (!expectedOwner)
|
|
696
|
+
return null;
|
|
697
|
+
if (canonicalHudPaneId && workerPaneId === canonicalHudPaneId) {
|
|
698
|
+
throw new Error(`tmux worker pane is HUD target: ${workerPaneId}`);
|
|
699
|
+
}
|
|
700
|
+
const proof = await readExactPaneProof(workerPaneId);
|
|
701
|
+
if (proof.status !== 'live')
|
|
702
|
+
return null;
|
|
703
|
+
if (proof.pid !== pinnedPid) {
|
|
704
|
+
throw new Error(`tmux pane identity changed: ${workerPaneId}`);
|
|
705
|
+
}
|
|
706
|
+
const owner = readPaneTeamOwnerTagResult(proof.paneId);
|
|
707
|
+
if (owner.status !== 'value' || owner.value !== expectedOwner) {
|
|
708
|
+
const detail = owner.status === 'error' ? owner.error : 'missing';
|
|
709
|
+
throw new Error(`tmux pane team owner changed: ${workerPaneId}: ${detail}`);
|
|
710
|
+
}
|
|
711
|
+
// The option read is untrusted and can race pane ID reuse. Its immediately
|
|
712
|
+
// adjacent PID proof authorizes the following capture/control/input effect.
|
|
713
|
+
const finalProof = await readExactPaneProof(workerPaneId);
|
|
714
|
+
if (finalProof.status !== 'live')
|
|
715
|
+
return null;
|
|
716
|
+
if (finalProof.pid !== pinnedPid) {
|
|
717
|
+
throw new Error(`tmux pane identity changed: ${workerPaneId}`);
|
|
718
|
+
}
|
|
719
|
+
lastResolvedPid = finalProof.pid;
|
|
720
|
+
return finalProof.paneId;
|
|
721
|
+
};
|
|
722
|
+
resolveTarget.lastResolvedPid = () => lastResolvedPid;
|
|
723
|
+
return resolveTarget;
|
|
724
|
+
}
|
|
725
|
+
async function requireAsyncPaneTarget(resolveTarget) {
|
|
726
|
+
const target = await resolveTarget();
|
|
727
|
+
if (!target)
|
|
728
|
+
throw new Error('tmux pane is not proven live');
|
|
729
|
+
return target;
|
|
730
|
+
}
|
|
731
|
+
async function sendKeyAsync(resolveTarget, key) {
|
|
732
|
+
const target = await requireAsyncPaneTarget(resolveTarget);
|
|
322
733
|
const result = await runTmuxAsync(['send-keys', '-t', target, key]);
|
|
323
734
|
if (!result.ok) {
|
|
324
735
|
throw new Error(`sendKeyAsync: failed to send ${key}: ${result.stderr}`);
|
|
325
736
|
}
|
|
326
737
|
}
|
|
327
|
-
async function capturePaneAsync(
|
|
738
|
+
async function capturePaneAsync(resolveTarget) {
|
|
739
|
+
const target = await resolveTarget();
|
|
740
|
+
if (!target)
|
|
741
|
+
return '';
|
|
328
742
|
const result = await runTmuxAsync(sharedBuildCapturePaneArgv(target, 80));
|
|
329
743
|
if (!result.ok)
|
|
330
744
|
return '';
|
|
331
745
|
return result.stdout;
|
|
332
746
|
}
|
|
333
|
-
|
|
747
|
+
/** Capture an explicit Team worker pane only after PID-owner-PID authorization. */
|
|
748
|
+
export async function captureWorkerPane(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
749
|
+
const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
|
|
750
|
+
return capturePaneAsync(resolveTarget);
|
|
751
|
+
}
|
|
752
|
+
async function captureVisiblePaneAsync(resolveTarget) {
|
|
753
|
+
const target = await resolveTarget();
|
|
754
|
+
if (!target)
|
|
755
|
+
return '';
|
|
334
756
|
const result = await runTmuxAsync(sharedBuildVisibleCapturePaneArgv(target));
|
|
335
757
|
if (!result.ok)
|
|
336
758
|
return '';
|
|
337
759
|
return result.stdout;
|
|
338
760
|
}
|
|
339
|
-
async function isWorkerAliveAsync(sessionName, workerIndex, workerPaneId) {
|
|
340
|
-
if (workerPaneId
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
761
|
+
async function isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) {
|
|
762
|
+
if (hasExplicitWorkerPaneId(workerPaneId)) {
|
|
763
|
+
const resolver = resolveTarget ?? createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
|
|
764
|
+
const target = await resolver();
|
|
765
|
+
const pid = resolver.lastResolvedPid?.() ?? expectedPanePid;
|
|
766
|
+
if (!target || typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0)
|
|
767
|
+
return 'unknown';
|
|
768
|
+
return probeProcessLiveness(pid);
|
|
344
769
|
}
|
|
345
770
|
const result = await runTmuxAsync([
|
|
346
771
|
'list-panes',
|
|
347
|
-
'-t', paneTarget(sessionName, workerIndex
|
|
772
|
+
'-t', paneTarget(sessionName, workerIndex),
|
|
348
773
|
'-F',
|
|
349
774
|
'#{pane_dead} #{pane_pid}',
|
|
350
775
|
]);
|
|
351
776
|
if (!result.ok)
|
|
352
|
-
return
|
|
777
|
+
return 'unknown';
|
|
353
778
|
const line = result.stdout.split('\n')[0]?.trim();
|
|
354
779
|
if (!line)
|
|
355
|
-
return
|
|
780
|
+
return 'unknown';
|
|
356
781
|
const parts = line.split(/\s+/);
|
|
357
782
|
if (parts.length < 2)
|
|
358
|
-
return
|
|
783
|
+
return 'unknown';
|
|
359
784
|
const paneDead = parts[0];
|
|
360
785
|
const pid = Number.parseInt(parts[1], 10);
|
|
361
786
|
if (paneDead === '1')
|
|
362
|
-
return
|
|
363
|
-
if (!Number.isFinite(pid))
|
|
364
|
-
return false;
|
|
365
|
-
try {
|
|
366
|
-
process.kill(pid, 0);
|
|
367
|
-
return true;
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
return false;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
function parsePaneLivenessLine(line, allowMissingPid) {
|
|
374
|
-
const parts = line.trim().split(/\s+/);
|
|
375
|
-
if (parts.length < 1 || parts[0] === '')
|
|
376
|
-
return null;
|
|
377
|
-
const paneDead = parts[0];
|
|
378
|
-
const pid = Number.parseInt(parts[1] ?? '', 10);
|
|
379
|
-
if (paneDead === '1')
|
|
380
|
-
return false;
|
|
787
|
+
return 'gone';
|
|
381
788
|
if (!Number.isFinite(pid))
|
|
382
|
-
return
|
|
383
|
-
|
|
384
|
-
process.kill(pid, 0);
|
|
385
|
-
return true;
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
return allowMissingPid;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
function readPaneLivenessById(paneId) {
|
|
392
|
-
const result = runTmux(['list-panes', '-a', '-F', '#{pane_id} #{pane_dead} #{pane_pid}']);
|
|
393
|
-
if (!result.ok)
|
|
394
|
-
return null;
|
|
395
|
-
for (const line of result.stdout.split('\n')) {
|
|
396
|
-
const trimmed = line.trim();
|
|
397
|
-
if (!trimmed)
|
|
398
|
-
continue;
|
|
399
|
-
const [id = '', ...rest] = trimmed.split(/\s+/);
|
|
400
|
-
if (id !== paneId)
|
|
401
|
-
continue;
|
|
402
|
-
return parsePaneLivenessLine(rest.join(' '), true);
|
|
403
|
-
}
|
|
404
|
-
return null;
|
|
405
|
-
}
|
|
406
|
-
async function readPaneLivenessByIdAsync(paneId) {
|
|
407
|
-
const result = await runTmuxAsync(['list-panes', '-a', '-F', '#{pane_id} #{pane_dead} #{pane_pid}']);
|
|
408
|
-
if (!result.ok)
|
|
409
|
-
return null;
|
|
410
|
-
for (const line of result.stdout.split('\n')) {
|
|
411
|
-
const trimmed = line.trim();
|
|
412
|
-
if (!trimmed)
|
|
413
|
-
continue;
|
|
414
|
-
const [id = '', ...rest] = trimmed.split(/\s+/);
|
|
415
|
-
if (id !== paneId)
|
|
416
|
-
continue;
|
|
417
|
-
return parsePaneLivenessLine(rest.join(' '), true);
|
|
418
|
-
}
|
|
419
|
-
return null;
|
|
789
|
+
return 'unknown';
|
|
790
|
+
return probeProcessLiveness(pid);
|
|
420
791
|
}
|
|
421
792
|
function shellQuoteSingle(value) {
|
|
422
793
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -510,6 +881,20 @@ function buildNestedTmuxShellCommand(command) {
|
|
|
510
881
|
function buildBestEffortShellCommand(command) {
|
|
511
882
|
return `${command} >/dev/null 2>&1 || true`;
|
|
512
883
|
}
|
|
884
|
+
function buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId, effectCommand = buildNestedTmuxShellCommand(buildHudResizeCommand(buildHudPaneTarget(hudPaneId), heightLines))) {
|
|
885
|
+
const target = buildHudPaneTarget(hudPaneId);
|
|
886
|
+
const snapshot = buildNestedTmuxShellCommand("list-panes -a -F '#{pane_id}\\t#{pane_dead}\\t#{pane_pid}'");
|
|
887
|
+
const expectedPid = typeof expectedPanePid === 'number' && Number.isSafeInteger(expectedPanePid) && expectedPanePid > 0
|
|
888
|
+
? ` && $3 == "${expectedPanePid}"`
|
|
889
|
+
: '';
|
|
890
|
+
const proof = `awk -F '\\t' -v pane='${target}' 'NF != 3 || $1 !~ /^%[0-9]+$/ || ($2 != "0" && $2 != "1") || (seen[$1]++) || ($1 == pane && ($3 !~ /^[1-9][0-9]*$/ || length($3) > 16 || (length($3) == 16 && ("x" $3) > "x9007199254740991"))) || ($1 != pane && $3 != "" && ($3 !~ /^[1-9][0-9]*$/ || length($3) > 16 || (length($3) == 16 && ("x" $3) > "x9007199254740991"))) { bad = 1 } $1 == pane && $2 == "0"${expectedPid} { live = 1 } END { exit bad || !live }'`;
|
|
891
|
+
const expectedOwner = expectedPaneOwnerId?.trim();
|
|
892
|
+
if (!expectedOwner) {
|
|
893
|
+
return `if snapshot=$(${snapshot}); then printf '%s\\n' "$snapshot" | ${proof} && ${effectCommand}; fi`;
|
|
894
|
+
}
|
|
895
|
+
const owner = buildNestedTmuxShellCommand(`show-option -qv -p -t ${target} ${OMX_TEAM_PANE_OWNER_OPTION}`);
|
|
896
|
+
return `if snapshot=$(${snapshot}); then printf '%s\\n' "$snapshot" | ${proof} && owner=$(${owner}) && [ "$owner" = ${shellQuoteSingle(expectedOwner)} ] && final_snapshot=$(${snapshot}) && printf '%s\\n' "$final_snapshot" | ${proof} && ${effectCommand}; fi`;
|
|
897
|
+
}
|
|
513
898
|
/** Upper bound for tmux hook indices (signed 32-bit max). */
|
|
514
899
|
const TMUX_HOOK_INDEX_MAX = 2147483647;
|
|
515
900
|
function buildResizeHookSlot(hookName) {
|
|
@@ -526,8 +911,8 @@ function buildClientAttachedHookSlot(hookName) {
|
|
|
526
911
|
}
|
|
527
912
|
return `client-attached[${Math.abs(hash) % TMUX_HOOK_INDEX_MAX}]`;
|
|
528
913
|
}
|
|
529
|
-
export function buildRegisterResizeHookArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
|
|
530
|
-
const resizeCommand = buildBestEffortShellCommand(
|
|
914
|
+
export function buildRegisterResizeHookArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
|
|
915
|
+
const resizeCommand = buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId));
|
|
531
916
|
const hookCommand = shellQuoteSingle(`${resizeCommand}; sleep ${HUD_RESIZE_RECONCILE_DELAY_SECONDS}; ${resizeCommand}`);
|
|
532
917
|
return ['set-hook', '-t', hookTarget, buildResizeHookSlot(hookName), `run-shell -b ${hookCommand}`];
|
|
533
918
|
}
|
|
@@ -543,9 +928,11 @@ export function buildClientAttachedReconcileHookName(teamName, sessionName, wind
|
|
|
543
928
|
normalizeHudPaneToken(hudPaneId),
|
|
544
929
|
].join('_');
|
|
545
930
|
}
|
|
546
|
-
export function buildRegisterClientAttachedReconcileArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
|
|
931
|
+
export function buildRegisterClientAttachedReconcileArgs(hookTarget, hookName, hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
|
|
547
932
|
const hookSlot = buildClientAttachedHookSlot(hookName);
|
|
548
|
-
const
|
|
933
|
+
const resizeCommand = buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId));
|
|
934
|
+
const unregisterCommand = buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId, buildNestedTmuxShellCommand(`set-hook -u -t ${hookTarget} ${hookSlot}`)));
|
|
935
|
+
const oneShotCommand = shellQuoteSingle(`${resizeCommand}; ${unregisterCommand}`);
|
|
549
936
|
return ['set-hook', '-t', hookTarget, hookSlot, `run-shell -b ${oneShotCommand}`];
|
|
550
937
|
}
|
|
551
938
|
export function buildUnregisterClientAttachedReconcileArgs(hookTarget, hookName) {
|
|
@@ -555,18 +942,19 @@ export function unregisterResizeHook(hookTarget, hookName) {
|
|
|
555
942
|
const result = runTmux(buildUnregisterResizeHookArgs(hookTarget, hookName));
|
|
556
943
|
return result.ok;
|
|
557
944
|
}
|
|
558
|
-
export function buildScheduleDelayedHudResizeArgs(hudPaneId, delaySeconds = HUD_RESIZE_RECONCILE_DELAY_SECONDS, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
|
|
945
|
+
export function buildScheduleDelayedHudResizeArgs(hudPaneId, delaySeconds = HUD_RESIZE_RECONCILE_DELAY_SECONDS, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
|
|
559
946
|
const delay = Number.isFinite(delaySeconds) && delaySeconds > 0 ? delaySeconds : HUD_RESIZE_RECONCILE_DELAY_SECONDS;
|
|
560
|
-
return ['run-shell', '-b', `sleep ${delay}; ${buildBestEffortShellCommand(
|
|
947
|
+
return ['run-shell', '-b', `sleep ${delay}; ${buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId))}`];
|
|
561
948
|
}
|
|
562
|
-
export function buildReconcileHudResizeArgs(hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES) {
|
|
563
|
-
return ['run-shell', buildBestEffortShellCommand(
|
|
949
|
+
export function buildReconcileHudResizeArgs(hudPaneId, heightLines = HUD_TMUX_TEAM_HEIGHT_LINES, expectedPanePid, expectedPaneOwnerId) {
|
|
950
|
+
return ['run-shell', buildBestEffortShellCommand(buildAuthoritativeHudResizeShellCommand(hudPaneId, heightLines, expectedPanePid, expectedPaneOwnerId))];
|
|
564
951
|
}
|
|
565
|
-
function redrawLeaderPaneAfterTeamLayout(leaderPaneId) {
|
|
952
|
+
function redrawLeaderPaneAfterTeamLayout(leaderPaneId, expectedPanePid, expectedTeamOwnerId) {
|
|
566
953
|
const target = leaderPaneId.trim();
|
|
567
954
|
if (!target.startsWith('%'))
|
|
568
955
|
return;
|
|
569
|
-
|
|
956
|
+
const provenTarget = requireLiveTeamOwnedPaneSync(target, expectedPanePid, expectedTeamOwnerId);
|
|
957
|
+
runTmux(['send-keys', '-t', provenTarget, 'C-l']);
|
|
570
958
|
}
|
|
571
959
|
const ZSH_CANDIDATE_PATHS = ['/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/local/bin/zsh', '/opt/homebrew/bin/zsh'];
|
|
572
960
|
const BASH_CANDIDATE_PATHS = ['/bin/bash', '/usr/bin/bash'];
|
|
@@ -1217,7 +1605,19 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1217
1605
|
const safeTeamName = sanitizeTeamName(teamName);
|
|
1218
1606
|
let registeredResizeHook = null;
|
|
1219
1607
|
let registeredClientAttachedHook = null;
|
|
1220
|
-
const
|
|
1608
|
+
const rollbackPanes = new Map();
|
|
1609
|
+
// An owner-tag command may have succeeded even if tmux reports an ambiguous
|
|
1610
|
+
// failure; rollback can only kill panes whose owner is re-authorized.
|
|
1611
|
+
const rollbackTaggedPaneOwnerIds = new Map();
|
|
1612
|
+
let partialTeamTarget = null;
|
|
1613
|
+
let partialLeaderPaneId = null;
|
|
1614
|
+
let partialLeaderPanePid;
|
|
1615
|
+
let partialTeamPaneOwnerId = '';
|
|
1616
|
+
const partialWorkerPaneIds = [];
|
|
1617
|
+
const partialWorkerPaneIdsByIndex = Array.from({ length: workerCount }, () => null);
|
|
1618
|
+
const partialWorkerPanePidsByIndex = Array.from({ length: workerCount }, () => null);
|
|
1619
|
+
let partialHudPaneId = null;
|
|
1620
|
+
let partialHudPanePid = null;
|
|
1221
1621
|
try {
|
|
1222
1622
|
const tmuxPaneTarget = process.env.TMUX_PANE;
|
|
1223
1623
|
const displayArgs = tmuxPaneTarget
|
|
@@ -1234,31 +1634,60 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1234
1634
|
throw new Error(`failed to parse current tmux target: ${context.stdout}`);
|
|
1235
1635
|
}
|
|
1236
1636
|
const teamTarget = `${sessionName}:${windowIndex}`;
|
|
1637
|
+
partialTeamTarget = teamTarget;
|
|
1237
1638
|
const ownerSessionId = (options.ownerSessionId ?? process.env.OMX_SESSION_ID ?? '').trim();
|
|
1238
1639
|
const teamPaneOwnerId = (options.teamPaneOwnerId ?? `team:${safeTeamName}`).trim();
|
|
1640
|
+
partialTeamPaneOwnerId = teamPaneOwnerId;
|
|
1641
|
+
const paneListResult = listPanesResult(teamTarget);
|
|
1642
|
+
if (paneListResult.error)
|
|
1643
|
+
throw new Error(`failed to read tmux pane topology: ${paneListResult.error}`);
|
|
1644
|
+
const leaderPaneId = chooseTeamLeaderPaneId(paneListResult.panes, detectedLeaderPaneId);
|
|
1645
|
+
const leaderProof = readExactPaneProofSync(leaderPaneId);
|
|
1646
|
+
if (leaderProof.status === 'unavailable')
|
|
1647
|
+
throw new ExactPaneProofUnavailableError(leaderProof);
|
|
1648
|
+
if (leaderProof.status === 'gone')
|
|
1649
|
+
throw new Error(`tmux pane is not proven live: ${leaderPaneId}`);
|
|
1650
|
+
const leaderPanePid = leaderProof.pid;
|
|
1651
|
+
partialLeaderPaneId = leaderPaneId;
|
|
1652
|
+
partialLeaderPanePid = leaderPanePid;
|
|
1653
|
+
const initialHudPaneIds = findHudWatchPaneIds(paneListResult.panes, leaderPaneId, { leaderPaneId });
|
|
1654
|
+
const initialWindowPanePids = new Map([[leaderPaneId, leaderPanePid]]);
|
|
1655
|
+
const omxEntry = resolveOmxCliEntryPath();
|
|
1656
|
+
const canRecreateTeamHud = Boolean(omxEntry && omxEntry.trim() !== '');
|
|
1657
|
+
// Freeze every HUD owned by this leader even when startup cannot recreate
|
|
1658
|
+
// it; the untouched pane remains part of the authorized topology.
|
|
1659
|
+
for (const hudPaneId of initialHudPaneIds) {
|
|
1660
|
+
const proof = readExactPaneProofSync(hudPaneId);
|
|
1661
|
+
if (proof.status === 'unavailable')
|
|
1662
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
1663
|
+
if (proof.status === 'gone')
|
|
1664
|
+
throw new Error(`tmux pane is not proven live: ${hudPaneId}`);
|
|
1665
|
+
initialWindowPanePids.set(hudPaneId, proof.pid);
|
|
1666
|
+
}
|
|
1667
|
+
// Team mode prioritizes leader + worker visibility. Remove HUD panes only
|
|
1668
|
+
// when we can recreate the team HUD. Otherwise keep the existing HUD alive
|
|
1669
|
+
// instead of making it disappear on team startup failures or broken installs.
|
|
1670
|
+
requireFrozenWindowTopologySync(teamTarget, initialWindowPanePids);
|
|
1239
1671
|
if (ownerSessionId) {
|
|
1240
1672
|
const tagResult = runTmux(['set-option', '-t', sessionName, OMX_INSTANCE_OPTION, ownerSessionId]);
|
|
1241
1673
|
if (!tagResult.ok) {
|
|
1242
1674
|
throw new Error(`failed to tag tmux session ${sessionName}: ${tagResult.stderr}`);
|
|
1243
1675
|
}
|
|
1244
1676
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
tagPaneInstance(leaderPaneId, ownerSessionId);
|
|
1248
|
-
tagPaneTeamOwner(leaderPaneId, teamPaneOwnerId);
|
|
1249
|
-
const initialHudPaneIds = findHudPaneIds(teamTarget, leaderPaneId);
|
|
1250
|
-
const omxEntry = resolveOmxCliEntryPath();
|
|
1251
|
-
const canRecreateTeamHud = Boolean(omxEntry && omxEntry.trim() !== '');
|
|
1252
|
-
// Team mode prioritizes leader + worker visibility. Remove HUD panes only
|
|
1253
|
-
// when we can recreate the team HUD. Otherwise keep the existing HUD alive
|
|
1254
|
-
// instead of making it disappear on team startup failures or broken installs.
|
|
1677
|
+
tagPaneInstance(leaderPaneId, ownerSessionId, leaderPanePid);
|
|
1678
|
+
tagPaneTeamOwner(leaderPaneId, teamPaneOwnerId, leaderPanePid);
|
|
1255
1679
|
if (canRecreateTeamHud) {
|
|
1256
|
-
for (const hudPaneId of
|
|
1257
|
-
|
|
1680
|
+
for (const [hudPaneId, hudPanePid] of initialWindowPanePids) {
|
|
1681
|
+
if (hudPaneId !== leaderPaneId)
|
|
1682
|
+
killExactPaneSync(hudPaneId, hudPanePid);
|
|
1258
1683
|
}
|
|
1259
1684
|
}
|
|
1260
1685
|
const workerPaneIds = [];
|
|
1686
|
+
const workerPanePidsByIndex = Array.from({ length: workerCount }, () => null);
|
|
1261
1687
|
let rightStackRootPaneId = null;
|
|
1688
|
+
let rightStackRootPanePid = null;
|
|
1689
|
+
const frozenWindowPanePids = new Map([[leaderPaneId, leaderPanePid]]);
|
|
1690
|
+
const frozenWindowPaneOwners = new Map([[leaderPaneId, teamPaneOwnerId]]);
|
|
1262
1691
|
for (let i = 1; i <= workerCount; i++) {
|
|
1263
1692
|
const startup = workerStartups[i - 1] || {};
|
|
1264
1693
|
const workerCwd = startup.cwd || cwd;
|
|
@@ -1269,7 +1698,7 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1269
1698
|
const cmd = writeWorkerStartupScriptCommand(safeTeamName, i, launchArgsForWorker, workerCwd, workerEnv, workerCliPlan[i - 1], startup.initialPrompt, startup.workerRole) ?? buildWorkerStartupCommand(safeTeamName, i, launchArgsForWorker, workerCwd, workerEnv, workerCliPlan[i - 1], startup.initialPrompt, startup.workerRole);
|
|
1270
1699
|
// First split creates the right side from leader. Remaining splits stack on the right.
|
|
1271
1700
|
const splitDirection = i === 1 ? '-h' : '-v';
|
|
1272
|
-
const splitTarget = i === 1 ? leaderPaneId : (rightStackRootPaneId ?? leaderPaneId);
|
|
1701
|
+
const splitTarget = requireLiveTeamOwnedPaneSync(i === 1 ? leaderPaneId : (rightStackRootPaneId ?? leaderPaneId), i === 1 ? leaderPanePid : (rightStackRootPanePid ?? leaderPanePid), teamPaneOwnerId);
|
|
1273
1702
|
const split = runTmux([
|
|
1274
1703
|
'split-window',
|
|
1275
1704
|
splitDirection,
|
|
@@ -1290,17 +1719,37 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1290
1719
|
if (!paneId || !paneId.startsWith('%')) {
|
|
1291
1720
|
throw new Error(`failed to capture worker pane id for worker ${i}`);
|
|
1292
1721
|
}
|
|
1293
|
-
|
|
1722
|
+
// The pane exists once split-window returns its concrete ID. Persist it in
|
|
1723
|
+
// rollback/partial state before its first proof: a malformed or unavailable
|
|
1724
|
+
// topology must retain cleanup debt, but cannot authorize an effect.
|
|
1725
|
+
rollbackPanes.set(paneId, null);
|
|
1726
|
+
partialWorkerPaneIds.push(paneId);
|
|
1727
|
+
partialWorkerPaneIdsByIndex[i - 1] = paneId;
|
|
1728
|
+
const paneProof = readExactPaneProofSync(paneId);
|
|
1729
|
+
if (paneProof.status === 'unavailable')
|
|
1730
|
+
throw new ExactPaneProofUnavailableError(paneProof);
|
|
1731
|
+
if (paneProof.status === 'gone')
|
|
1732
|
+
throw new Error(`tmux pane is not proven live: ${paneId}`);
|
|
1733
|
+
const panePid = paneProof.pid;
|
|
1734
|
+
rollbackPanes.set(paneId, panePid);
|
|
1735
|
+
partialWorkerPanePidsByIndex[i - 1] = panePid;
|
|
1736
|
+
workerPanePidsByIndex[i - 1] = panePid;
|
|
1737
|
+
frozenWindowPanePids.set(paneId, panePid);
|
|
1294
1738
|
if (isNativeWindows() && !waitForPaneToRemainPresent(teamTarget, paneId)) {
|
|
1295
1739
|
throw new Error(`worker pane ${i} did not remain present after tmux split-window returned ${paneId}`);
|
|
1296
1740
|
}
|
|
1297
|
-
tagPaneInstance(paneId, ownerSessionId);
|
|
1298
|
-
|
|
1741
|
+
tagPaneInstance(paneId, ownerSessionId, panePid);
|
|
1742
|
+
rollbackTaggedPaneOwnerIds.set(paneId, teamPaneOwnerId);
|
|
1743
|
+
tagPaneTeamOwner(paneId, teamPaneOwnerId, panePid);
|
|
1744
|
+
frozenWindowPaneOwners.set(paneId, teamPaneOwnerId);
|
|
1299
1745
|
workerPaneIds.push(paneId);
|
|
1300
|
-
if (i === 1)
|
|
1746
|
+
if (i === 1) {
|
|
1301
1747
|
rightStackRootPaneId = paneId;
|
|
1748
|
+
rightStackRootPanePid = panePid;
|
|
1749
|
+
}
|
|
1302
1750
|
}
|
|
1303
1751
|
// Keep leader as full left/main pane; workers stay stacked on the right.
|
|
1752
|
+
requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
|
|
1304
1753
|
runTmux(['select-layout', '-t', teamTarget, 'main-vertical']);
|
|
1305
1754
|
// Force leader pane to use half the window width.
|
|
1306
1755
|
const windowWidthResult = runTmux(['display-message', '-p', '-t', teamTarget, '#{window_width}']);
|
|
@@ -1308,7 +1757,9 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1308
1757
|
const width = Number.parseInt(windowWidthResult.stdout.split('\n')[0]?.trim() || '', 10);
|
|
1309
1758
|
if (Number.isFinite(width) && width >= 40) {
|
|
1310
1759
|
const half = String(Math.floor(width / 2));
|
|
1760
|
+
requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
|
|
1311
1761
|
runTmux(['set-window-option', '-t', teamTarget, 'main-pane-width', half]);
|
|
1762
|
+
requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
|
|
1312
1763
|
runTmux(['select-layout', '-t', teamTarget, 'main-vertical']);
|
|
1313
1764
|
}
|
|
1314
1765
|
}
|
|
@@ -1322,23 +1773,39 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1322
1773
|
if (canRecreateTeamHud && omxEntry) {
|
|
1323
1774
|
const hudCmd = `exec env ${formatHudEnvAssignments(process.env, { sessionId: ownerSessionId, leaderPaneId })} node ${shellQuoteSingle(translatePathForMsys(omxEntry))} hud --watch`;
|
|
1324
1775
|
const hudCwd = translatePathForMsys(cwd);
|
|
1776
|
+
requireFrozenWindowTopologySync(teamTarget, frozenWindowPanePids, frozenWindowPaneOwners);
|
|
1777
|
+
const hudSplitTarget = leaderPaneId;
|
|
1325
1778
|
const hudResult = runTmux([
|
|
1326
|
-
'split-window', '-v', '-f', '-l', String(HUD_TMUX_TEAM_HEIGHT_LINES), '-t',
|
|
1779
|
+
'split-window', '-v', '-f', '-l', String(HUD_TMUX_TEAM_HEIGHT_LINES), '-t', hudSplitTarget, '-d', '-P', '-F', '#{pane_id}', '-c', hudCwd, hudCmd,
|
|
1327
1780
|
]);
|
|
1328
1781
|
if (hudResult.ok) {
|
|
1329
1782
|
const id = hudResult.stdout.split('\n')[0]?.trim() ?? '';
|
|
1330
1783
|
if (id.startsWith('%')) {
|
|
1331
|
-
|
|
1784
|
+
// split-window has created a concrete pane even though no proof has
|
|
1785
|
+
// authorized an effect yet. Retain PID-less cleanup debt first.
|
|
1786
|
+
rollbackPanes.set(id, null);
|
|
1787
|
+
partialHudPaneId = id;
|
|
1788
|
+
partialHudPanePid = null;
|
|
1789
|
+
const hudProof = readExactPaneProofSync(id);
|
|
1790
|
+
if (hudProof.status === 'unavailable')
|
|
1791
|
+
throw new ExactPaneProofUnavailableError(hudProof);
|
|
1792
|
+
if (hudProof.status === 'gone')
|
|
1793
|
+
throw new Error(`tmux pane is not proven live: ${id}`);
|
|
1794
|
+
const hudPanePid = hudProof.pid;
|
|
1795
|
+
frozenWindowPanePids.set(id, hudPanePid);
|
|
1796
|
+
rollbackPanes.set(id, hudPanePid);
|
|
1797
|
+
partialHudPanePid = hudPanePid;
|
|
1332
1798
|
if (isNativeWindows() && !waitForPaneToRemainPresent(teamTarget, id)) {
|
|
1333
1799
|
throw new Error(`HUD pane did not remain present after tmux split-window returned ${id}`);
|
|
1334
1800
|
}
|
|
1335
|
-
tagPaneInstance(id, ownerSessionId);
|
|
1336
|
-
|
|
1801
|
+
tagPaneInstance(id, ownerSessionId, hudPanePid);
|
|
1802
|
+
rollbackTaggedPaneOwnerIds.set(id, teamPaneOwnerId);
|
|
1803
|
+
tagPaneTeamOwner(id, teamPaneOwnerId, hudPanePid);
|
|
1804
|
+
frozenWindowPaneOwners.set(id, teamPaneOwnerId);
|
|
1337
1805
|
hudPaneId = id;
|
|
1338
1806
|
if (isNativeWindows()) {
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
const reconcile = runTmux(buildHudResizeArgs(hudPaneId));
|
|
1807
|
+
const provenHudPaneId = requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
|
|
1808
|
+
const reconcile = runTmux(buildHudResizeArgs(provenHudPaneId));
|
|
1342
1809
|
if (!reconcile.ok) {
|
|
1343
1810
|
throw new Error(`failed to reconcile HUD resize: ${reconcile.stderr}`);
|
|
1344
1811
|
}
|
|
@@ -1346,36 +1813,50 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1346
1813
|
else {
|
|
1347
1814
|
const hookTarget = buildResizeHookTarget(sessionName, windowIndex);
|
|
1348
1815
|
const hookName = buildResizeHookName(safeTeamName, sessionName, windowIndex, hudPaneId);
|
|
1349
|
-
|
|
1816
|
+
requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
|
|
1817
|
+
const registerHook = runTmux(buildRegisterResizeHookArgs(hookTarget, hookName, hudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
|
|
1350
1818
|
const clientAttachedHookName = buildClientAttachedReconcileHookName(safeTeamName, sessionName, windowIndex, hudPaneId);
|
|
1351
1819
|
if (registerHook.ok) {
|
|
1352
1820
|
resizeHookTarget = hookTarget;
|
|
1353
1821
|
resizeHookName = hookName;
|
|
1354
|
-
registeredResizeHook = {
|
|
1822
|
+
registeredResizeHook = {
|
|
1823
|
+
name: resizeHookName,
|
|
1824
|
+
target: resizeHookTarget,
|
|
1825
|
+
leaderPaneId,
|
|
1826
|
+
leaderPanePid,
|
|
1827
|
+
hudPaneId,
|
|
1828
|
+
hudPanePid,
|
|
1829
|
+
teamPaneOwnerId,
|
|
1830
|
+
};
|
|
1355
1831
|
}
|
|
1356
1832
|
else {
|
|
1357
|
-
// tmux versions/builds that reject indexed client-resized hooks should not
|
|
1358
|
-
// abort madmax/team startup after panes were successfully created. Keep the
|
|
1359
|
-
// fallback narrow: skip only the long-lived resize hook metadata, then
|
|
1360
|
-
// still try the one-shot client-attached reconcile plus the explicit
|
|
1361
|
-
// delayed/direct resize checks below so real tmux/run-shell failures
|
|
1362
|
-
// still surface.
|
|
1363
1833
|
console.warn(`[omx] tmux resize hook unavailable for ${hookTarget} (${hookName}): ${registerHook.stderr}; `
|
|
1364
1834
|
+ 'continuing with best-effort HUD resize fallback.');
|
|
1365
1835
|
}
|
|
1366
|
-
|
|
1836
|
+
requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
|
|
1837
|
+
const registerClientAttachedHook = runTmux(buildRegisterClientAttachedReconcileArgs(hookTarget, clientAttachedHookName, hudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
|
|
1367
1838
|
if (registerClientAttachedHook.ok) {
|
|
1368
|
-
registeredClientAttachedHook = {
|
|
1839
|
+
registeredClientAttachedHook = {
|
|
1840
|
+
name: clientAttachedHookName,
|
|
1841
|
+
target: hookTarget,
|
|
1842
|
+
leaderPaneId,
|
|
1843
|
+
leaderPanePid,
|
|
1844
|
+
hudPaneId,
|
|
1845
|
+
hudPanePid,
|
|
1846
|
+
teamPaneOwnerId,
|
|
1847
|
+
};
|
|
1369
1848
|
}
|
|
1370
1849
|
else {
|
|
1371
1850
|
console.warn(`[omx] tmux client-attached resize fallback unavailable for ${hookTarget} `
|
|
1372
1851
|
+ `(${clientAttachedHookName}): ${registerClientAttachedHook.stderr}; continuing with delayed HUD resize fallback.`);
|
|
1373
1852
|
}
|
|
1374
|
-
|
|
1853
|
+
requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
|
|
1854
|
+
const delayed = runTmux(buildScheduleDelayedHudResizeArgs(hudPaneId, HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
|
|
1375
1855
|
if (!delayed.ok) {
|
|
1376
1856
|
console.warn(`[omx] tmux delayed HUD resize unavailable for ${hudPaneId}: ${delayed.stderr}; continuing.`);
|
|
1377
1857
|
}
|
|
1378
|
-
|
|
1858
|
+
requireLiveTeamOwnedPaneSync(hudPaneId, hudPanePid, teamPaneOwnerId);
|
|
1859
|
+
const reconcile = runTmux(buildReconcileHudResizeArgs(hudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid, teamPaneOwnerId));
|
|
1379
1860
|
if (!reconcile.ok) {
|
|
1380
1861
|
console.warn(`[omx] tmux HUD resize reconcile unavailable for ${hudPaneId}: ${reconcile.stderr}; continuing.`);
|
|
1381
1862
|
}
|
|
@@ -1383,21 +1864,31 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1383
1864
|
}
|
|
1384
1865
|
}
|
|
1385
1866
|
}
|
|
1386
|
-
runTmux(['select-pane', '-t', leaderPaneId]);
|
|
1387
|
-
redrawLeaderPaneAfterTeamLayout(leaderPaneId);
|
|
1867
|
+
runTmux(['select-pane', '-t', requireLiveTeamOwnedPaneSync(leaderPaneId, leaderPanePid, teamPaneOwnerId)]);
|
|
1868
|
+
redrawLeaderPaneAfterTeamLayout(leaderPaneId, leaderPanePid, teamPaneOwnerId);
|
|
1388
1869
|
sleepSeconds(0.5);
|
|
1389
1870
|
// Enable mouse scrolling so agent output panes can be scrolled with the
|
|
1390
1871
|
// mouse wheel without conflicting with keyboard up/down arrow-key input
|
|
1391
1872
|
// history navigation in the Codex CLI input field. (issue #103)
|
|
1392
1873
|
// Opt-out: set OMX_TEAM_MOUSE=0 in the environment.
|
|
1393
1874
|
if (process.env.OMX_TEAM_MOUSE !== '0') {
|
|
1394
|
-
enableMouseScrolling(sessionName);
|
|
1875
|
+
enableMouseScrolling(sessionName, () => { requireLiveTeamOwnedPaneSync(leaderPaneId, leaderPanePid, teamPaneOwnerId); });
|
|
1876
|
+
}
|
|
1877
|
+
const sessionIncarnation = teamTarget.includes(':') ? null : queryDetachedTeamSession(sessionName);
|
|
1878
|
+
if (sessionIncarnation && sessionIncarnation.status !== 'exact') {
|
|
1879
|
+
throw new Error(`tmux_session_incarnation_unavailable:${sessionName}`);
|
|
1395
1880
|
}
|
|
1396
1881
|
return {
|
|
1397
1882
|
name: teamTarget,
|
|
1883
|
+
tmuxSessionId: sessionIncarnation?.incarnation.sessionId,
|
|
1884
|
+
tmuxSessionCreated: sessionIncarnation?.incarnation.sessionCreated,
|
|
1398
1885
|
workerCount,
|
|
1399
1886
|
cwd,
|
|
1400
1887
|
workerPaneIds,
|
|
1888
|
+
workerPaneIdsByIndex: [...workerPaneIds],
|
|
1889
|
+
workerPanePidsByIndex,
|
|
1890
|
+
leaderPanePid,
|
|
1891
|
+
hudPanePid: partialHudPanePid,
|
|
1401
1892
|
leaderPaneId,
|
|
1402
1893
|
hudPaneId,
|
|
1403
1894
|
resizeHookName,
|
|
@@ -1406,14 +1897,82 @@ export function createTeamSession(teamName, workerCount, cwd, workerLaunchArgs =
|
|
|
1406
1897
|
};
|
|
1407
1898
|
}
|
|
1408
1899
|
catch (error) {
|
|
1409
|
-
|
|
1410
|
-
|
|
1900
|
+
const cleanupErrors = [];
|
|
1901
|
+
const unregisterAuthorizedHook = (hook, unregister, label) => {
|
|
1902
|
+
try {
|
|
1903
|
+
requireLiveTeamOwnedPaneSync(hook.leaderPaneId, hook.leaderPanePid, hook.teamPaneOwnerId);
|
|
1904
|
+
requireLiveTeamOwnedPaneSync(hook.hudPaneId, hook.hudPanePid, hook.teamPaneOwnerId);
|
|
1905
|
+
const unregistered = runTmux(unregister(hook.target, hook.name));
|
|
1906
|
+
if (unregistered.ok)
|
|
1907
|
+
return true;
|
|
1908
|
+
cleanupErrors.push(`failed to unregister tmux ${label} hook ${hook.name}: ${unregistered.stderr}`);
|
|
1909
|
+
}
|
|
1910
|
+
catch (cleanupError) {
|
|
1911
|
+
cleanupErrors.push(`unable to authorize tmux ${label} hook cleanup ${hook.name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
1912
|
+
}
|
|
1913
|
+
return false;
|
|
1914
|
+
};
|
|
1915
|
+
if (registeredClientAttachedHook && unregisterAuthorizedHook(registeredClientAttachedHook, buildUnregisterClientAttachedReconcileArgs, 'client-attached')) {
|
|
1916
|
+
registeredClientAttachedHook = null;
|
|
1917
|
+
}
|
|
1918
|
+
if (registeredResizeHook && unregisterAuthorizedHook(registeredResizeHook, buildUnregisterResizeHookArgs, 'resize')) {
|
|
1919
|
+
registeredResizeHook = null;
|
|
1411
1920
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1921
|
+
const proofUnavailable = [];
|
|
1922
|
+
if (error instanceof ExactPaneProofUnavailableError)
|
|
1923
|
+
proofUnavailable.push(error.proof);
|
|
1924
|
+
const unresolvedPaneIds = new Set(rollbackPanes.keys());
|
|
1925
|
+
for (const [paneId, panePid] of rollbackPanes) {
|
|
1926
|
+
// split-window IDs without a positive exact PID proof are cleanup debt,
|
|
1927
|
+
// never authorization to affect a potentially recycled pane ID.
|
|
1928
|
+
if (panePid === null)
|
|
1929
|
+
continue;
|
|
1930
|
+
try {
|
|
1931
|
+
const expectedOwnerId = rollbackTaggedPaneOwnerIds.get(paneId);
|
|
1932
|
+
killExactPaneSync(paneId, panePid, expectedOwnerId
|
|
1933
|
+
? () => { requireLiveTeamOwnedPaneSync(paneId, panePid, expectedOwnerId); }
|
|
1934
|
+
: undefined);
|
|
1935
|
+
unresolvedPaneIds.delete(paneId);
|
|
1936
|
+
}
|
|
1937
|
+
catch (cleanupError) {
|
|
1938
|
+
if (cleanupError instanceof ExactPaneProofUnavailableError) {
|
|
1939
|
+
proofUnavailable.push(cleanupError.proof);
|
|
1940
|
+
break;
|
|
1941
|
+
}
|
|
1942
|
+
else {
|
|
1943
|
+
cleanupErrors.push(cleanupError instanceof Error ? cleanupError.message : String(cleanupError));
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1414
1946
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1947
|
+
const unresolvedWorkerPaneIds = partialWorkerPaneIds.filter((paneId) => unresolvedPaneIds.has(paneId));
|
|
1948
|
+
const unresolvedWorkerPaneIdsByIndex = partialWorkerPaneIdsByIndex
|
|
1949
|
+
.map((paneId) => paneId && unresolvedPaneIds.has(paneId) ? paneId : null);
|
|
1950
|
+
const unresolvedWorkerPanePidsByIndex = partialWorkerPanePidsByIndex
|
|
1951
|
+
.map((panePid, index) => partialWorkerPaneIdsByIndex[index] && unresolvedPaneIds.has(partialWorkerPaneIdsByIndex[index]) ? panePid : null);
|
|
1952
|
+
const unresolvedHudPaneId = partialHudPaneId && (unresolvedPaneIds.has(partialHudPaneId)
|
|
1953
|
+
|| registeredResizeHook !== null
|
|
1954
|
+
|| registeredClientAttachedHook !== null)
|
|
1955
|
+
? partialHudPaneId
|
|
1956
|
+
: null;
|
|
1957
|
+
const hasRecoverablePartialArtifact = unresolvedPaneIds.size > 0
|
|
1958
|
+
|| registeredResizeHook !== null
|
|
1959
|
+
|| registeredClientAttachedHook !== null;
|
|
1960
|
+
if (hasRecoverablePartialArtifact && partialTeamTarget && partialLeaderPaneId) {
|
|
1961
|
+
throw new CreateTeamSessionPartialError({
|
|
1962
|
+
name: partialTeamTarget,
|
|
1963
|
+
workerCount,
|
|
1964
|
+
cwd,
|
|
1965
|
+
workerPaneIds: unresolvedWorkerPaneIds,
|
|
1966
|
+
workerPaneIdsByIndex: unresolvedWorkerPaneIdsByIndex,
|
|
1967
|
+
workerPanePidsByIndex: unresolvedWorkerPanePidsByIndex,
|
|
1968
|
+
leaderPanePid: partialLeaderPanePid,
|
|
1969
|
+
hudPanePid: unresolvedHudPaneId ? partialHudPanePid : null,
|
|
1970
|
+
leaderPaneId: partialLeaderPaneId,
|
|
1971
|
+
hudPaneId: unresolvedHudPaneId,
|
|
1972
|
+
resizeHookName: registeredResizeHook?.name ?? null,
|
|
1973
|
+
resizeHookTarget: registeredResizeHook?.target ?? null,
|
|
1974
|
+
teamPaneOwnerId: partialTeamPaneOwnerId,
|
|
1975
|
+
}, proofUnavailable, error, cleanupErrors);
|
|
1417
1976
|
}
|
|
1418
1977
|
throw error;
|
|
1419
1978
|
}
|
|
@@ -1422,34 +1981,73 @@ export function restoreStandaloneHudPane(leaderPaneId, cwd, options = {}) {
|
|
|
1422
1981
|
const normalizedLeaderPaneId = normalizePaneTarget(leaderPaneId);
|
|
1423
1982
|
if (!normalizedLeaderPaneId)
|
|
1424
1983
|
return null;
|
|
1984
|
+
// The split is irreversible without a durable cleanup obligation. Validate
|
|
1985
|
+
// its canonical Team-root location before authorizing any pane effect.
|
|
1986
|
+
restoredHudCleanupDebtPath(cwd, options.stateRoot);
|
|
1425
1987
|
const omxEntry = resolveOmxCliEntryPath();
|
|
1426
1988
|
if (!omxEntry || omxEntry.trim() === '')
|
|
1427
1989
|
return null;
|
|
1428
|
-
const
|
|
1990
|
+
const leaderPanePid = (() => {
|
|
1991
|
+
const proof = readExactPaneProofSync(normalizedLeaderPaneId);
|
|
1992
|
+
if (proof.status === 'unavailable')
|
|
1993
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
1994
|
+
if (proof.status === 'gone')
|
|
1995
|
+
throw new Error(`tmux pane is not proven live: ${normalizedLeaderPaneId}`);
|
|
1996
|
+
if (options.expectedLeaderPanePid !== undefined && proof.pid !== options.expectedLeaderPanePid) {
|
|
1997
|
+
throw new Error(`tmux pane identity changed: ${normalizedLeaderPaneId}`);
|
|
1998
|
+
}
|
|
1999
|
+
return proof.pid;
|
|
2000
|
+
})();
|
|
2001
|
+
const requireAuthorizedLeaderPane = () => {
|
|
2002
|
+
options.assertLeaderPaneAuthorization?.();
|
|
2003
|
+
return requireLiveExactPaneSync(normalizedLeaderPaneId, options.expectedLeaderPanePid ?? leaderPanePid);
|
|
2004
|
+
};
|
|
2005
|
+
requireAuthorizedLeaderPane();
|
|
2006
|
+
const paneListResult = listPanesResult(normalizedLeaderPaneId);
|
|
2007
|
+
if (paneListResult.error)
|
|
2008
|
+
throw new Error(`failed to read tmux pane topology: ${paneListResult.error}`);
|
|
2009
|
+
const [existingHudPaneId, ...duplicateHudPaneIds] = findHudWatchPaneIds(paneListResult.panes, normalizedLeaderPaneId, { leaderPaneId: normalizedLeaderPaneId });
|
|
2010
|
+
const ownedHudPanePids = new Map();
|
|
2011
|
+
for (const paneId of [existingHudPaneId, ...duplicateHudPaneIds]) {
|
|
2012
|
+
if (!paneId)
|
|
2013
|
+
continue;
|
|
2014
|
+
const proof = readExactPaneProofSync(paneId);
|
|
2015
|
+
if (proof.status === 'unavailable')
|
|
2016
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
2017
|
+
if (proof.status === 'gone')
|
|
2018
|
+
throw new Error(`tmux pane is not proven live: ${paneId}`);
|
|
2019
|
+
ownedHudPanePids.set(paneId, proof.pid);
|
|
2020
|
+
}
|
|
1429
2021
|
for (const paneId of duplicateHudPaneIds) {
|
|
1430
|
-
|
|
2022
|
+
killExactPaneSync(paneId, ownedHudPanePids.get(paneId), requireAuthorizedLeaderPane);
|
|
1431
2023
|
}
|
|
1432
2024
|
if (existingHudPaneId) {
|
|
1433
2025
|
if (isNativeWindows()) {
|
|
1434
|
-
|
|
2026
|
+
const exactHudPaneId = requireLiveExactPaneSync(existingHudPaneId, ownedHudPanePids.get(existingHudPaneId));
|
|
2027
|
+
requireAuthorizedLeaderPane();
|
|
2028
|
+
const reconcile = runTmux(buildHudResizeArgs(exactHudPaneId));
|
|
2029
|
+
if (!reconcile.ok)
|
|
2030
|
+
throw new Error(`failed to reconcile standalone HUD resize: ${reconcile.stderr}`);
|
|
1435
2031
|
}
|
|
1436
2032
|
else {
|
|
1437
|
-
|
|
1438
|
-
runTmux(
|
|
2033
|
+
requireAuthorizedLeaderPane();
|
|
2034
|
+
runTmux(buildScheduleDelayedHudResizeArgs(existingHudPaneId, HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES, ownedHudPanePids.get(existingHudPaneId)));
|
|
2035
|
+
requireAuthorizedLeaderPane();
|
|
2036
|
+
runTmux(buildReconcileHudResizeArgs(existingHudPaneId, HUD_TMUX_TEAM_HEIGHT_LINES, ownedHudPanePids.get(existingHudPaneId)));
|
|
1439
2037
|
}
|
|
1440
|
-
runTmux(['select-pane', '-t',
|
|
2038
|
+
runTmux(['select-pane', '-t', requireAuthorizedLeaderPane()]);
|
|
1441
2039
|
return existingHudPaneId;
|
|
1442
2040
|
}
|
|
1443
2041
|
const hudCmd = `exec env ${formatHudEnvAssignments(process.env, { sessionId: options.sessionId, leaderPaneId: normalizedLeaderPaneId })} ${shellQuoteSingle(translatePathForMsys(resolveLeaderNodePath()))} ${shellQuoteSingle(translatePathForMsys(omxEntry))} hud --watch`;
|
|
1444
2042
|
let hudResult = null;
|
|
1445
|
-
for (const restoreCwd of resolveStandaloneHudRestoreCwdCandidates(normalizedLeaderPaneId, cwd, options.cwd)) {
|
|
2043
|
+
for (const restoreCwd of resolveStandaloneHudRestoreCwdCandidates(normalizedLeaderPaneId, cwd, options.cwd, requireAuthorizedLeaderPane)) {
|
|
1446
2044
|
const candidateResult = runTmux([
|
|
1447
2045
|
'split-window',
|
|
1448
2046
|
'-v',
|
|
1449
2047
|
'-l',
|
|
1450
2048
|
String(HUD_TMUX_TEAM_HEIGHT_LINES),
|
|
1451
2049
|
'-t',
|
|
1452
|
-
|
|
2050
|
+
requireAuthorizedLeaderPane(),
|
|
1453
2051
|
'-d',
|
|
1454
2052
|
'-P',
|
|
1455
2053
|
'-F',
|
|
@@ -1468,14 +2066,48 @@ export function restoreStandaloneHudPane(leaderPaneId, cwd, options = {}) {
|
|
|
1468
2066
|
const paneId = hudResult.stdout.split('\n')[0]?.trim() ?? '';
|
|
1469
2067
|
if (!paneId.startsWith('%'))
|
|
1470
2068
|
return null;
|
|
2069
|
+
persistRestoredHudCleanupDebtSync(cwd, {
|
|
2070
|
+
schema_version: 1,
|
|
2071
|
+
operation: 'restored_hud_cleanup',
|
|
2072
|
+
pane_id: paneId,
|
|
2073
|
+
pane_pid: null,
|
|
2074
|
+
leader_pane_id: normalizedLeaderPaneId,
|
|
2075
|
+
leader_pane_pid: options.expectedLeaderPanePid ?? leaderPanePid,
|
|
2076
|
+
leader_pane_owner_id: options.expectedLeaderPaneOwnerId?.trim() || null,
|
|
2077
|
+
hud_owner_leader_pane_id: normalizedLeaderPaneId,
|
|
2078
|
+
}, options.stateRoot);
|
|
2079
|
+
const hudPanePid = (() => {
|
|
2080
|
+
const proof = readExactPaneProofSync(paneId);
|
|
2081
|
+
if (proof.status === 'unavailable')
|
|
2082
|
+
throw new ExactPaneProofUnavailableError(proof);
|
|
2083
|
+
if (proof.status === 'gone')
|
|
2084
|
+
throw new Error(`tmux pane is not proven live: ${paneId}`);
|
|
2085
|
+
return proof.pid;
|
|
2086
|
+
})();
|
|
2087
|
+
persistRestoredHudCleanupDebtSync(cwd, {
|
|
2088
|
+
schema_version: 1,
|
|
2089
|
+
operation: 'restored_hud_cleanup',
|
|
2090
|
+
pane_id: paneId,
|
|
2091
|
+
pane_pid: hudPanePid,
|
|
2092
|
+
leader_pane_id: normalizedLeaderPaneId,
|
|
2093
|
+
leader_pane_pid: options.expectedLeaderPanePid ?? leaderPanePid,
|
|
2094
|
+
leader_pane_owner_id: options.expectedLeaderPaneOwnerId?.trim() || null,
|
|
2095
|
+
hud_owner_leader_pane_id: normalizedLeaderPaneId,
|
|
2096
|
+
}, options.stateRoot);
|
|
1471
2097
|
if (isNativeWindows()) {
|
|
1472
|
-
|
|
2098
|
+
const exactHudPaneId = requireLiveExactPaneSync(paneId, hudPanePid);
|
|
2099
|
+
requireAuthorizedLeaderPane();
|
|
2100
|
+
const reconcile = runTmux(buildHudResizeArgs(exactHudPaneId));
|
|
2101
|
+
if (!reconcile.ok)
|
|
2102
|
+
throw new Error(`failed to reconcile standalone HUD resize: ${reconcile.stderr}`);
|
|
1473
2103
|
}
|
|
1474
2104
|
else {
|
|
1475
|
-
|
|
1476
|
-
runTmux(
|
|
2105
|
+
requireAuthorizedLeaderPane();
|
|
2106
|
+
runTmux(buildScheduleDelayedHudResizeArgs(paneId, HUD_RESIZE_RECONCILE_DELAY_SECONDS, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid));
|
|
2107
|
+
requireAuthorizedLeaderPane();
|
|
2108
|
+
runTmux(buildReconcileHudResizeArgs(paneId, HUD_TMUX_TEAM_HEIGHT_LINES, hudPanePid));
|
|
1477
2109
|
}
|
|
1478
|
-
runTmux(['select-pane', '-t',
|
|
2110
|
+
runTmux(['select-pane', '-t', requireAuthorizedLeaderPane()]);
|
|
1479
2111
|
return paneId;
|
|
1480
2112
|
}
|
|
1481
2113
|
/**
|
|
@@ -1487,21 +2119,21 @@ export function restoreStandaloneHudPane(leaderPaneId, cwd, options = {}) {
|
|
|
1487
2119
|
* oh-my-tmux, or other sessions. Returns true if the session mouse option
|
|
1488
2120
|
* was set successfully, false otherwise.
|
|
1489
2121
|
*/
|
|
1490
|
-
export function enableMouseScrolling(sessionTarget) {
|
|
2122
|
+
export function enableMouseScrolling(sessionTarget, beforeEffect) {
|
|
2123
|
+
beforeEffect?.();
|
|
1491
2124
|
const result = runTmux(['set-option', '-t', sessionTarget, 'mouse', 'on']);
|
|
1492
2125
|
if (!result.ok)
|
|
1493
2126
|
return false;
|
|
1494
2127
|
// Enable OSC 52 so copy-selection-and-cancel propagates selected text to
|
|
1495
2128
|
// the terminal's clipboard without requiring xclip or pbcopy. (closes #206)
|
|
2129
|
+
beforeEffect?.();
|
|
1496
2130
|
runTmux(['set-option', '-t', sessionTarget, 'set-clipboard', 'on']);
|
|
1497
2131
|
// Mouse selection enters tmux copy-mode. Keep the mitigation session-scoped
|
|
1498
2132
|
// so OMX does not mutate users' global tmux style defaults. (issue #1448)
|
|
1499
|
-
mitigateCopyModeUnderlineArtifacts(sessionTarget);
|
|
2133
|
+
mitigateCopyModeUnderlineArtifacts(sessionTarget, beforeEffect);
|
|
1500
2134
|
return true;
|
|
1501
2135
|
}
|
|
1502
|
-
function paneTarget(sessionName, workerIndex
|
|
1503
|
-
if (workerPaneId && workerPaneId.startsWith('%'))
|
|
1504
|
-
return workerPaneId;
|
|
2136
|
+
function paneTarget(sessionName, workerIndex) {
|
|
1505
2137
|
if (sessionName.includes(':')) {
|
|
1506
2138
|
return `${sessionName}.${workerIndex}`;
|
|
1507
2139
|
}
|
|
@@ -1547,24 +2179,50 @@ export function evaluateStartupDirectTriggerSafetyCapture(captured, workerCli) {
|
|
|
1547
2179
|
export async function evaluateStartupDirectTriggerSafety(sessionName, workerIndex, workerPaneId, workerCli) {
|
|
1548
2180
|
if (!isTmuxAvailable())
|
|
1549
2181
|
return { safe: false, reason: 'tmux_unavailable' };
|
|
1550
|
-
const target =
|
|
2182
|
+
const target = await resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId);
|
|
2183
|
+
if (!target)
|
|
2184
|
+
return { safe: false, reason: 'capture_failed' };
|
|
1551
2185
|
const result = await runTmuxAsync(sharedBuildVisibleCapturePaneArgv(target));
|
|
1552
2186
|
if (!result.ok)
|
|
1553
2187
|
return { safe: false, reason: 'capture_failed' };
|
|
1554
2188
|
return evaluateStartupDirectTriggerSafetyCapture(result.stdout, workerCli);
|
|
1555
2189
|
}
|
|
1556
|
-
function acceptClaudeBypassPermissionsPrompt(
|
|
1557
|
-
|
|
2190
|
+
function acceptClaudeBypassPermissionsPrompt(resolveTarget) {
|
|
2191
|
+
const literalTarget = resolveTarget();
|
|
2192
|
+
if (!literalTarget)
|
|
2193
|
+
return false;
|
|
2194
|
+
runTmux(['send-keys', '-t', literalTarget, '-l', '--', '2']);
|
|
1558
2195
|
sleepFractionalSeconds(0.12);
|
|
1559
|
-
|
|
2196
|
+
const submitTarget = resolveTarget();
|
|
2197
|
+
if (!submitTarget)
|
|
2198
|
+
return false;
|
|
2199
|
+
runTmux(['send-keys', '-t', submitTarget, 'C-m']);
|
|
2200
|
+
return true;
|
|
1560
2201
|
}
|
|
1561
|
-
function dismissClaudeBypassPermissionsPromptIfPresent(
|
|
2202
|
+
function dismissClaudeBypassPermissionsPromptIfPresent(resolveTarget, captured) {
|
|
1562
2203
|
if (process.env.OMX_TEAM_AUTO_ACCEPT_BYPASS === '0')
|
|
1563
2204
|
return false;
|
|
1564
2205
|
if (!paneHasClaudeBypassPermissionsPrompt(captured))
|
|
1565
2206
|
return false;
|
|
1566
|
-
acceptClaudeBypassPermissionsPrompt(
|
|
1567
|
-
|
|
2207
|
+
return acceptClaudeBypassPermissionsPrompt(resolveTarget);
|
|
2208
|
+
}
|
|
2209
|
+
async function dismissClaudeBypassPermissionsPromptIfPresentAsync(resolveTarget, captured) {
|
|
2210
|
+
if (process.env.OMX_TEAM_AUTO_ACCEPT_BYPASS === '0')
|
|
2211
|
+
return false;
|
|
2212
|
+
if (!paneHasClaudeBypassPermissionsPrompt(captured))
|
|
2213
|
+
return false;
|
|
2214
|
+
const literalTarget = await resolveTarget();
|
|
2215
|
+
if (!literalTarget)
|
|
2216
|
+
return false;
|
|
2217
|
+
const literalSend = await runTmuxAsync(['send-keys', '-t', literalTarget, '-l', '--', '2']);
|
|
2218
|
+
if (!literalSend.ok)
|
|
2219
|
+
return false;
|
|
2220
|
+
await sleep(120);
|
|
2221
|
+
const submitTarget = await resolveTarget();
|
|
2222
|
+
if (!submitTarget)
|
|
2223
|
+
return false;
|
|
2224
|
+
const submitSend = await runTmuxAsync(['send-keys', '-t', submitTarget, 'C-m']);
|
|
2225
|
+
return submitSend.ok;
|
|
1568
2226
|
}
|
|
1569
2227
|
export const paneHasActiveTask = sharedPaneHasActiveTask;
|
|
1570
2228
|
export function classifyWorkerStartupInjectSafety(captured) {
|
|
@@ -1581,8 +2239,8 @@ export function classifyWorkerStartupInjectSafety(captured) {
|
|
|
1581
2239
|
return 'safe';
|
|
1582
2240
|
}
|
|
1583
2241
|
export async function checkWorkerStartupInjectSafety(sessionName, workerIndex, workerPaneId) {
|
|
1584
|
-
const
|
|
1585
|
-
const visibleCapture = await captureVisiblePaneAsync(
|
|
2242
|
+
const resolveTarget = () => resolveWorkerPaneTargetAsync(sessionName, workerIndex, workerPaneId);
|
|
2243
|
+
const visibleCapture = await captureVisiblePaneAsync(resolveTarget);
|
|
1586
2244
|
const visibleSafety = classifyWorkerStartupInjectSafety(visibleCapture);
|
|
1587
2245
|
if (visibleSafety === 'safe')
|
|
1588
2246
|
return { safe: true, reason: 'safe' };
|
|
@@ -1591,7 +2249,7 @@ export async function checkWorkerStartupInjectSafety(sessionName, workerIndex, w
|
|
|
1591
2249
|
if (!sharedPaneShowsCodexViewport(visibleCapture)) {
|
|
1592
2250
|
return { safe: false, reason: visibleSafety };
|
|
1593
2251
|
}
|
|
1594
|
-
const scrollbackCapture = await capturePaneAsync(
|
|
2252
|
+
const scrollbackCapture = await capturePaneAsync(resolveTarget);
|
|
1595
2253
|
const scrollbackSafety = classifyWorkerStartupInjectSafety(scrollbackCapture);
|
|
1596
2254
|
return scrollbackSafety === 'safe'
|
|
1597
2255
|
? { safe: true, reason: 'safe' }
|
|
@@ -1669,8 +2327,9 @@ export function shouldAttemptAdaptiveRetry(strategy, paneBusyAtStart, allowAdapt
|
|
|
1669
2327
|
return false;
|
|
1670
2328
|
return true;
|
|
1671
2329
|
}
|
|
1672
|
-
function sendLiteralTextOrThrow(
|
|
1673
|
-
const
|
|
2330
|
+
async function sendLiteralTextOrThrow(resolveTarget, text) {
|
|
2331
|
+
const target = await requireAsyncPaneTarget(resolveTarget);
|
|
2332
|
+
const send = await runTmuxAsync(['send-keys', '-t', target, '-l', '--', text]);
|
|
1674
2333
|
if (!send.ok) {
|
|
1675
2334
|
throw new Error(`sendToWorker: failed to send text: ${send.stderr}`);
|
|
1676
2335
|
}
|
|
@@ -1682,18 +2341,18 @@ function paneHasQueuedCodexSubmission(captured) {
|
|
|
1682
2341
|
return /messages to be submitted after next tool call/i.test(normalized)
|
|
1683
2342
|
|| /press esc to interrupt and send immediately/i.test(normalized);
|
|
1684
2343
|
}
|
|
1685
|
-
async function attemptSubmitRounds(
|
|
2344
|
+
async function attemptSubmitRounds(resolveTarget, text, rounds, queueFirstRound, submitKeyPressesPerRound) {
|
|
1686
2345
|
const presses = Math.max(1, Math.floor(submitKeyPressesPerRound));
|
|
1687
2346
|
for (let round = 0; round < rounds; round++) {
|
|
1688
2347
|
await sleep(100);
|
|
1689
2348
|
if (round === 0 && queueFirstRound) {
|
|
1690
|
-
await sendKeyAsync(
|
|
2349
|
+
await sendKeyAsync(resolveTarget, 'Tab');
|
|
1691
2350
|
await sleep(80);
|
|
1692
|
-
await sendKeyAsync(
|
|
2351
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
1693
2352
|
}
|
|
1694
2353
|
else {
|
|
1695
2354
|
for (let press = 0; press < presses; press++) {
|
|
1696
|
-
await sendKeyAsync(
|
|
2355
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
1697
2356
|
if (press < presses - 1) {
|
|
1698
2357
|
await sleep(200);
|
|
1699
2358
|
}
|
|
@@ -1701,8 +2360,8 @@ async function attemptSubmitRounds(target, text, rounds, queueFirstRound, submit
|
|
|
1701
2360
|
}
|
|
1702
2361
|
await sleep(140);
|
|
1703
2362
|
const [captured, visibleCapture] = await Promise.all([
|
|
1704
|
-
capturePaneAsync(
|
|
1705
|
-
captureVisiblePaneAsync(
|
|
2363
|
+
capturePaneAsync(resolveTarget),
|
|
2364
|
+
captureVisiblePaneAsync(resolveTarget),
|
|
1706
2365
|
]);
|
|
1707
2366
|
const normalizedCapture = normalizeWorkerTriggerForDraftMatch(captured);
|
|
1708
2367
|
if (!normalizedCapture.includes(normalizeWorkerTriggerForDraftMatch(text))
|
|
@@ -1713,26 +2372,34 @@ async function attemptSubmitRounds(target, text, rounds, queueFirstRound, submit
|
|
|
1713
2372
|
}
|
|
1714
2373
|
return false;
|
|
1715
2374
|
}
|
|
1716
|
-
export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId) {
|
|
2375
|
+
export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
1717
2376
|
const initialBackoffMs = 150;
|
|
1718
2377
|
const maxBackoffMs = 8000;
|
|
1719
2378
|
const startedAt = Date.now();
|
|
1720
2379
|
let blockedByTrustPrompt = false;
|
|
1721
2380
|
let promptDismissed = false;
|
|
2381
|
+
const resolveTarget = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
|
|
1722
2382
|
const sendRobustEnter = () => {
|
|
1723
|
-
const target = paneTarget(sessionName, workerIndex, workerPaneId);
|
|
1724
2383
|
// Trust + follow-up splash can require two submits in Codex TUI.
|
|
1725
2384
|
// Use C-m (carriage return) for raw-mode compatibility.
|
|
1726
|
-
|
|
2385
|
+
const firstTarget = resolveTarget();
|
|
2386
|
+
if (!firstTarget)
|
|
2387
|
+
return;
|
|
2388
|
+
runTmux(['send-keys', '-t', firstTarget, 'C-m']);
|
|
1727
2389
|
sleepFractionalSeconds(0.12);
|
|
1728
|
-
|
|
2390
|
+
const secondTarget = resolveTarget();
|
|
2391
|
+
if (!secondTarget)
|
|
2392
|
+
return;
|
|
2393
|
+
runTmux(['send-keys', '-t', secondTarget, 'C-m']);
|
|
1729
2394
|
};
|
|
1730
2395
|
const check = () => {
|
|
1731
|
-
const target =
|
|
2396
|
+
const target = resolveTarget();
|
|
2397
|
+
if (!target)
|
|
2398
|
+
return false;
|
|
1732
2399
|
const result = runTmux(sharedBuildVisibleCapturePaneArgv(target));
|
|
1733
2400
|
if (!result.ok)
|
|
1734
2401
|
return false;
|
|
1735
|
-
if (dismissClaudeBypassPermissionsPromptIfPresent(
|
|
2402
|
+
if (dismissClaudeBypassPermissionsPromptIfPresent(resolveTarget, result.stdout)) {
|
|
1736
2403
|
promptDismissed = true;
|
|
1737
2404
|
return false;
|
|
1738
2405
|
}
|
|
@@ -1757,7 +2424,10 @@ export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000,
|
|
|
1757
2424
|
// scrollback for the prompt/helper text that may have slipped below the fold.
|
|
1758
2425
|
if (!sharedPaneShowsCodexViewport(result.stdout))
|
|
1759
2426
|
return false;
|
|
1760
|
-
const
|
|
2427
|
+
const scrollbackTarget = resolveTarget();
|
|
2428
|
+
if (!scrollbackTarget)
|
|
2429
|
+
return false;
|
|
2430
|
+
const scrollbackResult = runTmux(sharedBuildCapturePaneArgv(scrollbackTarget, 80));
|
|
1761
2431
|
if (!scrollbackResult.ok)
|
|
1762
2432
|
return false;
|
|
1763
2433
|
return paneLooksReady(scrollbackResult.stdout);
|
|
@@ -1785,26 +2455,34 @@ export function waitForWorkerReady(sessionName, workerIndex, timeoutMs = 30_000,
|
|
|
1785
2455
|
// Async twin of waitForWorkerReady for team startup fan-out. Keep the readiness
|
|
1786
2456
|
// semantics mirrored with the synchronous helper above, but yield between polls
|
|
1787
2457
|
// so one slow worker pane cannot block later workers' startup attempts.
|
|
1788
|
-
export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId) {
|
|
2458
|
+
export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutMs = 30_000, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
1789
2459
|
const initialBackoffMs = 150;
|
|
1790
2460
|
const maxBackoffMs = 8000;
|
|
1791
2461
|
const startedAt = Date.now();
|
|
1792
2462
|
let blockedByTrustPrompt = false;
|
|
1793
2463
|
let promptDismissed = false;
|
|
2464
|
+
const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
|
|
1794
2465
|
const sendRobustEnter = async () => {
|
|
1795
|
-
const target = paneTarget(sessionName, workerIndex, workerPaneId);
|
|
1796
2466
|
// Trust + follow-up splash can require two submits in Codex TUI.
|
|
1797
2467
|
// Use C-m (carriage return) for raw-mode compatibility.
|
|
1798
|
-
|
|
2468
|
+
const firstTarget = await resolveTarget();
|
|
2469
|
+
if (!firstTarget)
|
|
2470
|
+
return;
|
|
2471
|
+
await runTmuxAsync(['send-keys', '-t', firstTarget, 'C-m']);
|
|
1799
2472
|
await sleep(120);
|
|
1800
|
-
|
|
2473
|
+
const secondTarget = await resolveTarget();
|
|
2474
|
+
if (!secondTarget)
|
|
2475
|
+
return;
|
|
2476
|
+
await runTmuxAsync(['send-keys', '-t', secondTarget, 'C-m']);
|
|
1801
2477
|
};
|
|
1802
2478
|
const check = async () => {
|
|
1803
|
-
const target =
|
|
2479
|
+
const target = await resolveTarget();
|
|
2480
|
+
if (!target)
|
|
2481
|
+
return false;
|
|
1804
2482
|
const result = await runTmuxAsync(sharedBuildVisibleCapturePaneArgv(target));
|
|
1805
2483
|
if (!result.ok)
|
|
1806
2484
|
return false;
|
|
1807
|
-
if (
|
|
2485
|
+
if (await dismissClaudeBypassPermissionsPromptIfPresentAsync(resolveTarget, result.stdout)) {
|
|
1808
2486
|
promptDismissed = true;
|
|
1809
2487
|
return false;
|
|
1810
2488
|
}
|
|
@@ -1829,7 +2507,10 @@ export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutM
|
|
|
1829
2507
|
// scrollback for the prompt/helper text that may have slipped below the fold.
|
|
1830
2508
|
if (!sharedPaneShowsCodexViewport(result.stdout))
|
|
1831
2509
|
return false;
|
|
1832
|
-
const
|
|
2510
|
+
const scrollbackTarget = await resolveTarget();
|
|
2511
|
+
if (!scrollbackTarget)
|
|
2512
|
+
return false;
|
|
2513
|
+
const scrollbackResult = await runTmuxAsync(sharedBuildCapturePaneArgv(scrollbackTarget, 80));
|
|
1833
2514
|
if (!scrollbackResult.ok)
|
|
1834
2515
|
return false;
|
|
1835
2516
|
return paneLooksReady(scrollbackResult.stdout);
|
|
@@ -1859,21 +2540,30 @@ export async function waitForWorkerReadyAsync(sessionName, workerIndex, timeoutM
|
|
|
1859
2540
|
* Returns true if a trust prompt was found and dismissed, false otherwise.
|
|
1860
2541
|
* Opt-out: set OMX_TEAM_AUTO_TRUST=0 to disable auto-dismissal.
|
|
1861
2542
|
*/
|
|
1862
|
-
export function dismissTrustPromptIfPresent(sessionName, workerIndex, workerPaneId) {
|
|
2543
|
+
export function dismissTrustPromptIfPresent(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
1863
2544
|
if (process.env.OMX_TEAM_AUTO_TRUST === '0')
|
|
1864
2545
|
return false;
|
|
1865
2546
|
if (!isTmuxAvailable())
|
|
1866
2547
|
return false;
|
|
1867
|
-
const
|
|
1868
|
-
const
|
|
2548
|
+
const resolveTarget = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
|
|
2549
|
+
const captureTarget = resolveTarget();
|
|
2550
|
+
if (!captureTarget)
|
|
2551
|
+
return false;
|
|
2552
|
+
const result = runTmux(sharedBuildVisibleCapturePaneArgv(captureTarget));
|
|
1869
2553
|
if (!result.ok)
|
|
1870
2554
|
return false;
|
|
1871
2555
|
if (!paneHasTrustPrompt(result.stdout))
|
|
1872
2556
|
return false;
|
|
1873
2557
|
// Trust prompt detected; send C-m twice to dismiss (trust + follow-up splash)
|
|
1874
|
-
|
|
2558
|
+
const firstTarget = resolveTarget();
|
|
2559
|
+
if (!firstTarget)
|
|
2560
|
+
return false;
|
|
2561
|
+
runTmux(['send-keys', '-t', firstTarget, 'C-m']);
|
|
1875
2562
|
sleepFractionalSeconds(0.12);
|
|
1876
|
-
|
|
2563
|
+
const secondTarget = resolveTarget();
|
|
2564
|
+
if (!secondTarget)
|
|
2565
|
+
return false;
|
|
2566
|
+
runTmux(['send-keys', '-t', secondTarget, 'C-m']);
|
|
1877
2567
|
return true;
|
|
1878
2568
|
}
|
|
1879
2569
|
export const normalizeTmuxCapture = sharedNormalizeTmuxCapture;
|
|
@@ -1904,49 +2594,49 @@ export function sendToWorkerStdin(stdin, text) {
|
|
|
1904
2594
|
// Send SHORT text (<200 chars) to worker via tmux send-keys
|
|
1905
2595
|
// Validates: text < 200 chars, no injection marker
|
|
1906
2596
|
// Throws on violation
|
|
1907
|
-
export async function sendToWorker(sessionName, workerIndex, text, workerPaneId, workerCli) {
|
|
2597
|
+
export async function sendToWorker(sessionName, workerIndex, text, workerPaneId, workerCli, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
1908
2598
|
assertWorkerTriggerText(text);
|
|
1909
|
-
const
|
|
2599
|
+
const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
|
|
1910
2600
|
const strategy = resolveSendStrategyFromEnv();
|
|
1911
2601
|
const resolvedWorkerCli = resolveWorkerCliForSend(workerIndex, workerCli);
|
|
1912
2602
|
// Guard: if the trust prompt is still present, advance it first so our trigger text
|
|
1913
2603
|
// doesn't get typed into the trust screen and ignored.
|
|
1914
|
-
const capturedStr = await capturePaneAsync(
|
|
2604
|
+
const capturedStr = await capturePaneAsync(resolveTarget);
|
|
1915
2605
|
const paneBusy = paneHasActiveTask(capturedStr);
|
|
1916
|
-
if (
|
|
2606
|
+
if (await dismissClaudeBypassPermissionsPromptIfPresentAsync(resolveTarget, capturedStr)) {
|
|
1917
2607
|
await sleep(200);
|
|
1918
2608
|
}
|
|
1919
2609
|
if (paneHasTrustPrompt(capturedStr)) {
|
|
1920
|
-
await sendKeyAsync(
|
|
2610
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
1921
2611
|
await sleep(120);
|
|
1922
|
-
await sendKeyAsync(
|
|
2612
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
1923
2613
|
await sleep(200);
|
|
1924
2614
|
}
|
|
1925
|
-
sendLiteralTextOrThrow(
|
|
2615
|
+
await sendLiteralTextOrThrow(resolveTarget, text);
|
|
1926
2616
|
// Allow the input buffer to settle before sending C-m
|
|
1927
2617
|
await sleep(150);
|
|
1928
2618
|
const allowAutoInterruptRetry = process.env[OMX_TEAM_AUTO_INTERRUPT_RETRY_ENV] !== '0';
|
|
1929
2619
|
const submitPlan = buildWorkerSubmitPlan(strategy, resolvedWorkerCli, paneBusy, allowAutoInterruptRetry);
|
|
1930
2620
|
if (submitPlan.shouldInterrupt) {
|
|
1931
2621
|
// Explicit interrupt mode: abort current turn first, then submit the new command.
|
|
1932
|
-
await sendKeyAsync(
|
|
2622
|
+
await sendKeyAsync(resolveTarget, 'C-c');
|
|
1933
2623
|
await sleep(100);
|
|
1934
2624
|
}
|
|
1935
2625
|
// Submit deterministically using CLI-specific plan:
|
|
1936
2626
|
// - Codex: queue-first Tab+C-m when configured/busy, then double C-m rounds.
|
|
1937
2627
|
// - Claude: direct C-m rounds only (never queue-first Tab).
|
|
1938
|
-
if (await attemptSubmitRounds(
|
|
2628
|
+
if (await attemptSubmitRounds(resolveTarget, text, submitPlan.rounds, submitPlan.queueFirstRound, submitPlan.submitKeyPressesPerRound))
|
|
1939
2629
|
return;
|
|
1940
2630
|
// Adaptive escalation for "likely unsent trigger text at ready prompt" cases:
|
|
1941
2631
|
// clear line, re-send trigger, then re-submit with deterministic C-m rounds.
|
|
1942
|
-
const latestCapture = await capturePaneAsync(
|
|
2632
|
+
const latestCapture = await capturePaneAsync(resolveTarget);
|
|
1943
2633
|
if (shouldAttemptAdaptiveRetry(strategy, paneBusy, submitPlan.allowAdaptiveRetry, latestCapture || null, text)) {
|
|
1944
2634
|
// Keep this branch non-interrupting to avoid canceling active turns on false positives.
|
|
1945
|
-
await sendKeyAsync(
|
|
2635
|
+
await sendKeyAsync(resolveTarget, 'C-u');
|
|
1946
2636
|
await sleep(80);
|
|
1947
|
-
sendLiteralTextOrThrow(
|
|
2637
|
+
await sendLiteralTextOrThrow(resolveTarget, text);
|
|
1948
2638
|
await sleep(120);
|
|
1949
|
-
if (await attemptSubmitRounds(
|
|
2639
|
+
if (await attemptSubmitRounds(resolveTarget, text, 4, false, submitPlan.submitKeyPressesPerRound))
|
|
1950
2640
|
return;
|
|
1951
2641
|
}
|
|
1952
2642
|
// Fail-open by default: Codex may keep the last submitted line visible even after executing it.
|
|
@@ -1956,15 +2646,15 @@ export async function sendToWorker(sessionName, workerIndex, text, workerPaneId,
|
|
|
1956
2646
|
throw new Error('sendToWorker: submit_failed (trigger text still visible after retries)');
|
|
1957
2647
|
}
|
|
1958
2648
|
// One last best-effort double C-m nudge, then verify.
|
|
1959
|
-
await sendKeyAsync(
|
|
2649
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
1960
2650
|
await sleep(120);
|
|
1961
|
-
await sendKeyAsync(
|
|
2651
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
1962
2652
|
// Post-submit verification: wait briefly and confirm the worker consumed the
|
|
1963
2653
|
// trigger (draft disappeared or active-task indicator appeared). Fixes #391.
|
|
1964
2654
|
await sleep(300);
|
|
1965
2655
|
const [verifyCapture, verifyVisibleCapture] = await Promise.all([
|
|
1966
|
-
capturePaneAsync(
|
|
1967
|
-
captureVisiblePaneAsync(
|
|
2656
|
+
capturePaneAsync(resolveTarget),
|
|
2657
|
+
captureVisiblePaneAsync(resolveTarget),
|
|
1968
2658
|
]);
|
|
1969
2659
|
if (verifyCapture) {
|
|
1970
2660
|
if (paneHasActiveTask(verifyCapture))
|
|
@@ -1974,14 +2664,14 @@ export async function sendToWorker(sessionName, workerIndex, text, workerPaneId,
|
|
|
1974
2664
|
return;
|
|
1975
2665
|
}
|
|
1976
2666
|
// Draft still visible and no active task — one more C-m attempt.
|
|
1977
|
-
await sendKeyAsync(
|
|
2667
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
1978
2668
|
await sleep(150);
|
|
1979
|
-
await sendKeyAsync(
|
|
1980
|
-
const finalVisibleCapture = await captureVisiblePaneAsync(
|
|
2669
|
+
await sendKeyAsync(resolveTarget, 'C-m');
|
|
2670
|
+
const finalVisibleCapture = await captureVisiblePaneAsync(resolveTarget);
|
|
1981
2671
|
if (paneHasQueuedCodexSubmission(finalVisibleCapture)) {
|
|
1982
2672
|
throw new Error('sendToWorker: submit_queued_after_tool_call');
|
|
1983
2673
|
}
|
|
1984
|
-
const finalCapture = await capturePaneAsync(
|
|
2674
|
+
const finalCapture = await capturePaneAsync(resolveTarget);
|
|
1985
2675
|
if (normalizeWorkerTriggerForDraftMatch(finalCapture).includes(normalizeWorkerTriggerForDraftMatch(text))
|
|
1986
2676
|
&& !paneHasActiveTask(finalCapture)
|
|
1987
2677
|
&& paneLooksReady(finalCapture)) {
|
|
@@ -2000,8 +2690,12 @@ export function notifyLeaderStatus(sessionName, message) {
|
|
|
2000
2690
|
return result.ok;
|
|
2001
2691
|
}
|
|
2002
2692
|
// Get PID of the shell process in a worker's tmux pane
|
|
2003
|
-
export function getWorkerPanePid(sessionName, workerIndex, workerPaneId) {
|
|
2004
|
-
|
|
2693
|
+
export function getWorkerPanePid(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
2694
|
+
if (hasExplicitWorkerPaneId(workerPaneId)) {
|
|
2695
|
+
const target = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId)();
|
|
2696
|
+
return target ? expectedPanePid ?? null : null;
|
|
2697
|
+
}
|
|
2698
|
+
const result = runTmux(['list-panes', '-t', paneTarget(sessionName, workerIndex), '-F', '#{pane_pid}']);
|
|
2005
2699
|
if (!result.ok)
|
|
2006
2700
|
return null;
|
|
2007
2701
|
const firstLine = result.stdout.split('\n')[0]?.trim();
|
|
@@ -2013,15 +2707,16 @@ export function getWorkerPanePid(sessionName, workerIndex, workerPaneId) {
|
|
|
2013
2707
|
return pid;
|
|
2014
2708
|
}
|
|
2015
2709
|
// Check if worker's tmux pane has a running process
|
|
2016
|
-
export function isWorkerAlive(sessionName, workerIndex, workerPaneId) {
|
|
2017
|
-
if (workerPaneId
|
|
2018
|
-
const
|
|
2019
|
-
if (
|
|
2020
|
-
return
|
|
2710
|
+
export function isWorkerAlive(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
2711
|
+
if (hasExplicitWorkerPaneId(workerPaneId)) {
|
|
2712
|
+
const target = createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId)();
|
|
2713
|
+
if (!target || typeof expectedPanePid !== 'number')
|
|
2714
|
+
return false;
|
|
2715
|
+
return probeProcessLiveness(expectedPanePid) !== 'gone';
|
|
2021
2716
|
}
|
|
2022
2717
|
const result = runTmux([
|
|
2023
2718
|
'list-panes',
|
|
2024
|
-
'-t', paneTarget(sessionName, workerIndex
|
|
2719
|
+
'-t', paneTarget(sessionName, workerIndex),
|
|
2025
2720
|
'-F',
|
|
2026
2721
|
'#{pane_dead} #{pane_pid}',
|
|
2027
2722
|
]);
|
|
@@ -2039,23 +2734,17 @@ export function isWorkerAlive(sessionName, workerIndex, workerPaneId) {
|
|
|
2039
2734
|
return false;
|
|
2040
2735
|
if (!Number.isFinite(pid))
|
|
2041
2736
|
return false;
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
}
|
|
2046
|
-
catch {
|
|
2047
|
-
return false;
|
|
2048
|
-
}
|
|
2737
|
+
// Unknown is conservatively live so callers cannot classify a permission or
|
|
2738
|
+
// transient failure as stale cleanup authority.
|
|
2739
|
+
return probeProcessLiveness(pid) !== 'gone';
|
|
2049
2740
|
}
|
|
2050
|
-
export function isWorkerPaneOpen(sessionName, workerIndex, workerPaneId) {
|
|
2051
|
-
if (workerPaneId
|
|
2052
|
-
|
|
2053
|
-
if (paneStatus !== null)
|
|
2054
|
-
return paneStatus;
|
|
2741
|
+
export function isWorkerPaneOpen(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
2742
|
+
if (hasExplicitWorkerPaneId(workerPaneId)) {
|
|
2743
|
+
return createPinnedWorkerPaneTargetResolverSync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId)() !== null;
|
|
2055
2744
|
}
|
|
2056
2745
|
const result = runTmux([
|
|
2057
2746
|
'list-panes',
|
|
2058
|
-
'-t', paneTarget(sessionName, workerIndex
|
|
2747
|
+
'-t', paneTarget(sessionName, workerIndex),
|
|
2059
2748
|
'-F',
|
|
2060
2749
|
'#{pane_dead}',
|
|
2061
2750
|
]);
|
|
@@ -2068,28 +2757,69 @@ export function isWorkerPaneOpen(sessionName, workerIndex, workerPaneId) {
|
|
|
2068
2757
|
}
|
|
2069
2758
|
// Kill a specific worker: send C-c, then C-d, then kill-pane if still alive.
|
|
2070
2759
|
// leaderPaneId: when provided, the kill is skipped entirely if workerPaneId matches it.
|
|
2071
|
-
export async function killWorker(sessionName, workerIndex, workerPaneId, leaderPaneId) {
|
|
2760
|
+
export async function killWorker(sessionName, workerIndex, workerPaneId, leaderPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId) {
|
|
2072
2761
|
// Guard: never kill the leader's own pane.
|
|
2073
2762
|
if (leaderPaneId && workerPaneId === leaderPaneId)
|
|
2074
2763
|
return;
|
|
2075
|
-
|
|
2764
|
+
const resolveTarget = createPinnedWorkerPaneTargetResolver(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId);
|
|
2765
|
+
const initialTarget = await resolveTarget();
|
|
2766
|
+
if (!initialTarget)
|
|
2767
|
+
return;
|
|
2768
|
+
if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) !== 'alive')
|
|
2769
|
+
return;
|
|
2770
|
+
await runTmuxAsync(['send-keys', '-t', initialTarget, 'C-c']);
|
|
2076
2771
|
await sleep(1000);
|
|
2077
|
-
if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId)) {
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2772
|
+
if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) === 'alive') {
|
|
2773
|
+
const exitTarget = await resolveTarget();
|
|
2774
|
+
if (exitTarget) {
|
|
2775
|
+
await runTmuxAsync(['send-keys', '-t', exitTarget, 'C-d']);
|
|
2776
|
+
await sleep(1000);
|
|
2777
|
+
}
|
|
2083
2778
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2779
|
+
if (await isWorkerAliveAsync(sessionName, workerIndex, workerPaneId, expectedPanePid, expectedTeamOwnerId, hudPaneId, resolveTarget) === 'alive') {
|
|
2780
|
+
const killTarget = await resolveTarget();
|
|
2781
|
+
if (!killTarget)
|
|
2782
|
+
return;
|
|
2783
|
+
const killed = await runTmuxAsync(['kill-pane', '-t', killTarget]);
|
|
2784
|
+
if (!killed.ok)
|
|
2785
|
+
throw new Error(`failed to kill tmux pane ${killTarget}: ${killed.stderr}`);
|
|
2786
|
+
const absence = await readExactPaneProof(killTarget);
|
|
2787
|
+
if (absence.status === 'unavailable')
|
|
2788
|
+
throw new ExactPaneProofUnavailableError(absence);
|
|
2789
|
+
if (absence.status !== 'gone')
|
|
2790
|
+
throw new Error(`tmux pane remains live after kill: ${killTarget}`);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
// Explicit pane targets require frozen PID, canonical Team owner, and HUD
|
|
2794
|
+
// exclusion. A successful kill is only accepted after a fresh absence proof.
|
|
2795
|
+
export function killWorkerByPaneId(workerPaneId, expectedPanePid, leaderPaneId, expectedTeamOwnerId, hudPaneId) {
|
|
2796
|
+
const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
|
|
2797
|
+
if (!hasExplicitWorkerPaneId(workerPaneId)
|
|
2798
|
+
|| typeof expectedPanePid !== 'number'
|
|
2799
|
+
|| !Number.isSafeInteger(expectedPanePid)
|
|
2800
|
+
|| expectedPanePid <= 0
|
|
2801
|
+
|| !expectedOwner
|
|
2802
|
+
|| workerPaneId === hudPaneId)
|
|
2088
2803
|
return;
|
|
2089
|
-
// Guard: never kill the leader's own pane.
|
|
2090
2804
|
if (leaderPaneId && workerPaneId === leaderPaneId)
|
|
2091
2805
|
return;
|
|
2092
|
-
|
|
2806
|
+
const proof = readExactPaneProofSync(workerPaneId);
|
|
2807
|
+
if (proof.status !== 'live' || proof.pid !== expectedPanePid)
|
|
2808
|
+
return;
|
|
2809
|
+
const owner = readPaneTeamOwnerTagResult(proof.paneId);
|
|
2810
|
+
if (owner.status !== 'value' || owner.value !== expectedOwner)
|
|
2811
|
+
return;
|
|
2812
|
+
const finalProof = readExactPaneProofSync(proof.paneId);
|
|
2813
|
+
if (finalProof.status !== 'live' || finalProof.pid !== expectedPanePid)
|
|
2814
|
+
return;
|
|
2815
|
+
const result = runTmux(['kill-pane', '-t', finalProof.paneId]);
|
|
2816
|
+
if (!result.ok)
|
|
2817
|
+
throw new Error(`failed to kill tmux pane ${finalProof.paneId}: ${result.stderr}`);
|
|
2818
|
+
const absence = readExactPaneProofSync(finalProof.paneId);
|
|
2819
|
+
if (absence.status === 'unavailable')
|
|
2820
|
+
throw new ExactPaneProofUnavailableError(absence);
|
|
2821
|
+
if (absence.status !== 'gone')
|
|
2822
|
+
throw new Error(`tmux pane remains live after kill: ${finalProof.paneId}`);
|
|
2093
2823
|
}
|
|
2094
2824
|
export function paneHasOmxInstanceTag(paneId, instanceId) {
|
|
2095
2825
|
const normalizedPaneId = normalizePaneTarget(paneId);
|
|
@@ -2140,13 +2870,34 @@ export function readPaneTeamOwnerTagResult(paneId) {
|
|
|
2140
2870
|
return { status: 'missing' };
|
|
2141
2871
|
return { status: 'error', error: stderr || `tmux show-option exited ${result.status ?? 'unknown'}` };
|
|
2142
2872
|
}
|
|
2143
|
-
export async function killWorkerByPaneIdAsync(workerPaneId, leaderPaneId) {
|
|
2144
|
-
|
|
2873
|
+
export async function killWorkerByPaneIdAsync(workerPaneId, expectedPanePid, leaderPaneId, expectedTeamOwnerId, hudPaneId) {
|
|
2874
|
+
const expectedOwner = typeof expectedTeamOwnerId === 'string' ? expectedTeamOwnerId.trim() : '';
|
|
2875
|
+
if (!hasExplicitWorkerPaneId(workerPaneId)
|
|
2876
|
+
|| typeof expectedPanePid !== 'number'
|
|
2877
|
+
|| !Number.isSafeInteger(expectedPanePid)
|
|
2878
|
+
|| expectedPanePid <= 0
|
|
2879
|
+
|| !expectedOwner
|
|
2880
|
+
|| workerPaneId === hudPaneId)
|
|
2145
2881
|
return;
|
|
2146
|
-
// Guard: never kill the leader's own pane.
|
|
2147
2882
|
if (leaderPaneId && workerPaneId === leaderPaneId)
|
|
2148
2883
|
return;
|
|
2149
|
-
await
|
|
2884
|
+
const proof = await readExactPaneProof(workerPaneId);
|
|
2885
|
+
if (proof.status !== 'live' || proof.pid !== expectedPanePid)
|
|
2886
|
+
return;
|
|
2887
|
+
const owner = readPaneTeamOwnerTagResult(proof.paneId);
|
|
2888
|
+
if (owner.status !== 'value' || owner.value !== expectedOwner)
|
|
2889
|
+
return;
|
|
2890
|
+
const finalProof = await readExactPaneProof(proof.paneId);
|
|
2891
|
+
if (finalProof.status !== 'live' || finalProof.pid !== expectedPanePid)
|
|
2892
|
+
return;
|
|
2893
|
+
const result = await runTmuxAsync(['kill-pane', '-t', finalProof.paneId]);
|
|
2894
|
+
if (!result.ok)
|
|
2895
|
+
throw new Error(`failed to kill tmux pane ${finalProof.paneId}: ${result.stderr}`);
|
|
2896
|
+
const absence = await readExactPaneProof(finalProof.paneId);
|
|
2897
|
+
if (absence.status === 'unavailable')
|
|
2898
|
+
throw new ExactPaneProofUnavailableError(absence);
|
|
2899
|
+
if (absence.status !== 'gone')
|
|
2900
|
+
throw new Error(`tmux pane remains live after kill: ${finalProof.paneId}`);
|
|
2150
2901
|
}
|
|
2151
2902
|
function normalizePaneTarget(value) {
|
|
2152
2903
|
if (typeof value !== 'string')
|
|
@@ -2184,7 +2935,10 @@ function normalizePaneTargets(paneIds, options = {}) {
|
|
|
2184
2935
|
return { killablePaneIds, excluded };
|
|
2185
2936
|
}
|
|
2186
2937
|
export function resolveSharedSessionShutdownTopology(sessionName, preferredLeaderPaneId, teamName) {
|
|
2187
|
-
const
|
|
2938
|
+
const paneList = listPanesResult(sessionName);
|
|
2939
|
+
if (paneList.error !== null)
|
|
2940
|
+
return { status: 'unavailable', detail: paneList.error };
|
|
2941
|
+
const panes = paneList.panes;
|
|
2188
2942
|
const livePaneIds = panes
|
|
2189
2943
|
.map((pane) => normalizePaneTarget(pane.paneId))
|
|
2190
2944
|
.filter((paneId) => Boolean(paneId));
|
|
@@ -2192,6 +2946,7 @@ export function resolveSharedSessionShutdownTopology(sessionName, preferredLeade
|
|
|
2192
2946
|
if (panes.length === 0) {
|
|
2193
2947
|
return {
|
|
2194
2948
|
livePaneIds,
|
|
2949
|
+
status: 'available',
|
|
2195
2950
|
teamWorkerPaneIds: [],
|
|
2196
2951
|
leaderPaneId: fallbackLeaderPaneId,
|
|
2197
2952
|
hudPaneIds: [],
|
|
@@ -2222,6 +2977,7 @@ export function resolveSharedSessionShutdownTopology(sessionName, preferredLeade
|
|
|
2222
2977
|
: [];
|
|
2223
2978
|
return {
|
|
2224
2979
|
livePaneIds,
|
|
2980
|
+
status: 'available',
|
|
2225
2981
|
teamWorkerPaneIds: normalizedTeamWorkerPaneIds,
|
|
2226
2982
|
leaderPaneId: resolvedLeaderPaneId,
|
|
2227
2983
|
hudPaneIds,
|
|
@@ -2336,7 +3092,8 @@ function escapeRegExp(value) {
|
|
|
2336
3092
|
}
|
|
2337
3093
|
/**
|
|
2338
3094
|
* Shared pane-id-direct teardown primitive for worker pane cleanup.
|
|
2339
|
-
*
|
|
3095
|
+
* A fresh exact global proof is required before each kill; proven-gone panes
|
|
3096
|
+
* are teardown-compatible while unavailable proofs remain fail-closed.
|
|
2340
3097
|
*/
|
|
2341
3098
|
export async function teardownWorkerPanes(paneIds, options = {}) {
|
|
2342
3099
|
const { killablePaneIds, excluded } = normalizePaneTargets(paneIds, options);
|
|
@@ -2347,45 +3104,247 @@ export async function teardownWorkerPanes(paneIds, options = {}) {
|
|
|
2347
3104
|
const summary = {
|
|
2348
3105
|
attemptedPaneIds: killablePaneIds,
|
|
2349
3106
|
excluded,
|
|
3107
|
+
provenGonePaneIds: [],
|
|
3108
|
+
killedPaneIds: [],
|
|
3109
|
+
proofUnavailable: [],
|
|
2350
3110
|
kill: {
|
|
2351
|
-
attempted:
|
|
3111
|
+
attempted: 0,
|
|
2352
3112
|
succeeded: 0,
|
|
2353
3113
|
failed: 0,
|
|
3114
|
+
failedPaneIds: [],
|
|
2354
3115
|
},
|
|
2355
3116
|
};
|
|
2356
3117
|
for (const paneId of killablePaneIds) {
|
|
2357
|
-
const
|
|
2358
|
-
|
|
3118
|
+
const proof = await readExactPaneProof(paneId);
|
|
3119
|
+
const expectedPid = options.expectedPanePids?.[paneId];
|
|
3120
|
+
if (proof.status === 'gone') {
|
|
3121
|
+
summary.provenGonePaneIds.push(proof.paneId);
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
if (proof.status === 'unavailable') {
|
|
3125
|
+
summary.proofUnavailable.push(proof);
|
|
3126
|
+
break;
|
|
3127
|
+
}
|
|
3128
|
+
if (typeof expectedPid !== 'number' || !Number.isSafeInteger(expectedPid) || expectedPid <= 0) {
|
|
3129
|
+
summary.proofUnavailable.push({
|
|
3130
|
+
status: 'unavailable',
|
|
3131
|
+
paneId,
|
|
3132
|
+
reason: 'pane_pid_changed',
|
|
3133
|
+
detail: 'expected positive pane PID is required before teardown kill',
|
|
3134
|
+
});
|
|
3135
|
+
break;
|
|
3136
|
+
}
|
|
3137
|
+
if (proof.pid !== expectedPid) {
|
|
3138
|
+
summary.proofUnavailable.push({
|
|
3139
|
+
status: 'unavailable',
|
|
3140
|
+
paneId,
|
|
3141
|
+
reason: 'pane_pid_changed',
|
|
3142
|
+
detail: `expected ${expectedPid}, got ${proof.pid}`,
|
|
3143
|
+
});
|
|
3144
|
+
break;
|
|
3145
|
+
}
|
|
3146
|
+
if (options.authorizePaneKill && !options.authorizePaneKill(proof.paneId, proof)) {
|
|
3147
|
+
summary.proofUnavailable.push({
|
|
3148
|
+
status: 'unavailable',
|
|
3149
|
+
paneId: proof.paneId,
|
|
3150
|
+
reason: 'pane_pid_changed',
|
|
3151
|
+
detail: 'pane owner authorization changed',
|
|
3152
|
+
});
|
|
3153
|
+
break;
|
|
3154
|
+
}
|
|
3155
|
+
// Owner authorization can read tmux. Re-prove the persisted identity after
|
|
3156
|
+
// it and immediately before kill-pane.
|
|
3157
|
+
const finalProof = await readExactPaneProof(proof.paneId);
|
|
3158
|
+
if (finalProof.status === 'gone') {
|
|
3159
|
+
summary.provenGonePaneIds.push(finalProof.paneId);
|
|
3160
|
+
continue;
|
|
3161
|
+
}
|
|
3162
|
+
if (finalProof.status === 'unavailable') {
|
|
3163
|
+
summary.proofUnavailable.push(finalProof);
|
|
3164
|
+
break;
|
|
3165
|
+
}
|
|
3166
|
+
if (finalProof.pid !== expectedPid) {
|
|
3167
|
+
summary.proofUnavailable.push({
|
|
3168
|
+
status: 'unavailable',
|
|
3169
|
+
paneId: finalProof.paneId,
|
|
3170
|
+
reason: 'pane_pid_changed',
|
|
3171
|
+
detail: `expected ${expectedPid}, got ${finalProof.pid}`,
|
|
3172
|
+
});
|
|
3173
|
+
break;
|
|
3174
|
+
}
|
|
3175
|
+
summary.kill.attempted += 1;
|
|
3176
|
+
const result = await runTmuxAsync(['kill-pane', '-t', finalProof.paneId]);
|
|
3177
|
+
if (!result.ok) {
|
|
3178
|
+
summary.kill.failed += 1;
|
|
3179
|
+
summary.kill.failedPaneIds.push(proof.paneId);
|
|
3180
|
+
await sleep(perPaneGrace);
|
|
3181
|
+
break;
|
|
3182
|
+
}
|
|
3183
|
+
const afterKill = await readExactPaneProof(finalProof.paneId);
|
|
3184
|
+
if (afterKill.status === 'gone') {
|
|
2359
3185
|
summary.kill.succeeded += 1;
|
|
2360
|
-
|
|
3186
|
+
summary.killedPaneIds.push(proof.paneId);
|
|
3187
|
+
}
|
|
3188
|
+
else if (afterKill.status === 'unavailable') {
|
|
3189
|
+
summary.proofUnavailable.push(afterKill);
|
|
3190
|
+
break;
|
|
3191
|
+
}
|
|
3192
|
+
else {
|
|
2361
3193
|
summary.kill.failed += 1;
|
|
3194
|
+
summary.kill.failedPaneIds.push(proof.paneId);
|
|
3195
|
+
await sleep(perPaneGrace);
|
|
3196
|
+
break;
|
|
3197
|
+
}
|
|
2362
3198
|
await sleep(perPaneGrace);
|
|
2363
3199
|
}
|
|
2364
3200
|
return summary;
|
|
2365
3201
|
}
|
|
2366
|
-
export async function killWorkerPanes(paneIds, leaderPaneId, graceMs = 2000, hudPaneId) {
|
|
2367
|
-
return teardownWorkerPanes(paneIds, {
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
}
|
|
2374
|
-
catch {
|
|
2375
|
-
// tolerate
|
|
2376
|
-
}
|
|
3202
|
+
export async function killWorkerPanes(paneIds, leaderPaneId, graceMs = 2000, hudPaneId, expectedPanePids) {
|
|
3203
|
+
return teardownWorkerPanes(paneIds, {
|
|
3204
|
+
leaderPaneId,
|
|
3205
|
+
hudPaneId: hudPaneId ?? null,
|
|
3206
|
+
graceMs,
|
|
3207
|
+
expectedPanePids,
|
|
3208
|
+
});
|
|
2377
3209
|
}
|
|
2378
|
-
//
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
return result.stdout
|
|
3210
|
+
// Kill an entire detached tmux session only when the caller has already
|
|
3211
|
+
// established ownership. Success requires tmux to accept the kill and a fresh
|
|
3212
|
+
// session-list proof that the exact base session is absent.
|
|
3213
|
+
function parseTeamSessionNames(stdout) {
|
|
3214
|
+
return stdout
|
|
2384
3215
|
.split('\n')
|
|
2385
3216
|
.map(line => line.trim())
|
|
2386
3217
|
.filter(Boolean)
|
|
2387
3218
|
.map(baseSessionName);
|
|
2388
3219
|
}
|
|
3220
|
+
function isFinalTmuxServerGone(result) {
|
|
3221
|
+
return !result.ok && /^no server running on .+$/i.test(result.stderr);
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* Reads pane and session incarnation in one tmux snapshot. This prevents a
|
|
3225
|
+
* same-name replacement from combining a stale session proof with a live pane.
|
|
3226
|
+
*/
|
|
3227
|
+
export function queryDetachedSessionLeaderBinding(paneId, pid, sessionName, expected) {
|
|
3228
|
+
const result = runTmuxStructured(['list-panes', '-a', '-F', '#{pane_id}\t#{pane_dead}\t#{pane_pid}\t#{session_name}\t#{session_id}\t#{session_created}']);
|
|
3229
|
+
if (!result.ok)
|
|
3230
|
+
return null;
|
|
3231
|
+
const seenPaneIds = new Set();
|
|
3232
|
+
let binding = null;
|
|
3233
|
+
for (const line of result.stdout.split('\n').filter(Boolean)) {
|
|
3234
|
+
const fields = line.split('\t');
|
|
3235
|
+
if (fields.length !== 6)
|
|
3236
|
+
return null;
|
|
3237
|
+
const [currentPaneId, dead, panePid, currentSessionName, sessionId, sessionCreated] = fields;
|
|
3238
|
+
if (!/^%[0-9]+$/.test(currentPaneId) || seenPaneIds.has(currentPaneId) || (dead !== '0' && dead !== '1')
|
|
3239
|
+
|| !/^[0-9]+$/.test(panePid) || !Number.isSafeInteger(Number(panePid)) || Number(panePid) <= 0
|
|
3240
|
+
|| !currentSessionName || !/^\$[0-9]+$/.test(sessionId) || !/^[0-9]+$/.test(sessionCreated))
|
|
3241
|
+
return null;
|
|
3242
|
+
seenPaneIds.add(currentPaneId);
|
|
3243
|
+
if (currentPaneId !== paneId)
|
|
3244
|
+
continue;
|
|
3245
|
+
if (dead !== '0' || Number(panePid) !== pid || currentSessionName !== sessionName
|
|
3246
|
+
|| sessionId !== expected.sessionId || sessionCreated !== expected.sessionCreated)
|
|
3247
|
+
return null;
|
|
3248
|
+
binding = { paneId: currentPaneId, pid: Number(panePid), sessionName: currentSessionName, incarnation: { sessionId, sessionCreated } };
|
|
3249
|
+
}
|
|
3250
|
+
return binding;
|
|
3251
|
+
}
|
|
3252
|
+
function parseDetachedSessionIncarnation(stdout, sessionName) {
|
|
3253
|
+
const baseName = baseSessionName(sessionName);
|
|
3254
|
+
const matches = [];
|
|
3255
|
+
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
3256
|
+
const fields = line.split('\t');
|
|
3257
|
+
if (fields.length !== 3)
|
|
3258
|
+
return 'malformed';
|
|
3259
|
+
const [name, sessionId, sessionCreated] = fields;
|
|
3260
|
+
if (!name || !sessionId || !sessionCreated || !/^\$[0-9]+$/.test(sessionId) || !/^[0-9]+$/.test(sessionCreated))
|
|
3261
|
+
return 'malformed';
|
|
3262
|
+
if (baseSessionName(name) === baseName)
|
|
3263
|
+
matches.push({ sessionId, sessionCreated });
|
|
3264
|
+
}
|
|
3265
|
+
return matches.length === 0 ? null : matches.length === 1 ? matches[0] : 'malformed';
|
|
3266
|
+
}
|
|
3267
|
+
/**
|
|
3268
|
+
* Reads stable tmux incarnation evidence. Name-only presence is never effect
|
|
3269
|
+
* authority: malformed or unavailable evidence fails closed, and an expected
|
|
3270
|
+
* incarnation distinguishes an exact survivor from a recycled same-name session.
|
|
3271
|
+
*/
|
|
3272
|
+
export function queryDetachedTeamSession(sessionName, expected) {
|
|
3273
|
+
const sessions = runTmuxStructured(['list-sessions', '-F', '#{session_name}\t#{session_id}\t#{session_created}']);
|
|
3274
|
+
if (!sessions.ok) {
|
|
3275
|
+
return isFinalTmuxServerGone(sessions)
|
|
3276
|
+
? { status: 'absent' }
|
|
3277
|
+
: { status: 'unavailable', detail: sessions.stderr };
|
|
3278
|
+
}
|
|
3279
|
+
const incarnation = parseDetachedSessionIncarnation(sessions.stdout, sessionName);
|
|
3280
|
+
if (incarnation === 'malformed')
|
|
3281
|
+
return { status: 'unavailable', detail: 'malformed_session_incarnation' };
|
|
3282
|
+
if (incarnation === null)
|
|
3283
|
+
return { status: 'absent' };
|
|
3284
|
+
if (expected && (incarnation.sessionId !== expected.sessionId || incarnation.sessionCreated !== expected.sessionCreated)) {
|
|
3285
|
+
return { status: 'replacement', incarnation };
|
|
3286
|
+
}
|
|
3287
|
+
return { status: 'exact', incarnation };
|
|
3288
|
+
}
|
|
3289
|
+
/**
|
|
3290
|
+
* Re-proves every stable authorization component at the destructive sink. The
|
|
3291
|
+
* final tmux command queues an in-server predicate with the kill, so no client
|
|
3292
|
+
* can replace the session or pane between authorization and the effect.
|
|
3293
|
+
*/
|
|
3294
|
+
function buildDetachedSessionDestroyArgs(expected) {
|
|
3295
|
+
const leaderPaneId = expected.leaderPaneId.trim();
|
|
3296
|
+
const ownerId = expected.ownerId.trim();
|
|
3297
|
+
if (!/^%[0-9]+$/.test(leaderPaneId)
|
|
3298
|
+
|| !Number.isSafeInteger(expected.leaderPanePid)
|
|
3299
|
+
|| expected.leaderPanePid <= 0
|
|
3300
|
+
|| !/^\$[0-9]+$/.test(expected.sessionId)
|
|
3301
|
+
|| !/^[0-9]+$/.test(expected.sessionCreated)
|
|
3302
|
+
|| !/^[A-Za-z0-9._:@/-]+$/.test(ownerId))
|
|
3303
|
+
return null;
|
|
3304
|
+
const predicate = [
|
|
3305
|
+
'#{==:#{pane_dead},0}',
|
|
3306
|
+
`#{==:#{pane_id},${leaderPaneId}}`,
|
|
3307
|
+
`#{==:#{pane_pid},${expected.leaderPanePid}}`,
|
|
3308
|
+
`#{==:#{${OMX_TEAM_PANE_OWNER_OPTION}},${ownerId}}`,
|
|
3309
|
+
`#{==:#{session_id},${expected.sessionId}}`,
|
|
3310
|
+
`#{==:#{session_created},${expected.sessionCreated}}`,
|
|
3311
|
+
].reduce((combined, condition) => `#{&&:${combined},${condition}}`);
|
|
3312
|
+
return ['if-shell', '-F', '-t', leaderPaneId, predicate, `kill-session -t ${expected.sessionId}`, 'run-shell "exit 1"'];
|
|
3313
|
+
}
|
|
3314
|
+
export function requestDetachedTeamSessionDestroy(sessionName, expected) {
|
|
3315
|
+
const incarnation = { sessionId: expected.sessionId, sessionCreated: expected.sessionCreated };
|
|
3316
|
+
if (queryDetachedTeamSession(sessionName, incarnation).status !== 'exact')
|
|
3317
|
+
return false;
|
|
3318
|
+
if (!queryDetachedSessionLeaderBinding(expected.leaderPaneId, expected.leaderPanePid, sessionName, incarnation))
|
|
3319
|
+
return false;
|
|
3320
|
+
const pane = readExactPaneProofSync(expected.leaderPaneId);
|
|
3321
|
+
if (pane.status !== 'live' || pane.pid !== expected.leaderPanePid)
|
|
3322
|
+
return false;
|
|
3323
|
+
const owner = readPaneTeamOwnerTagResult(expected.leaderPaneId);
|
|
3324
|
+
if (owner.status !== 'value' || owner.value !== expected.ownerId)
|
|
3325
|
+
return false;
|
|
3326
|
+
const args = buildDetachedSessionDestroyArgs(expected);
|
|
3327
|
+
return args !== null && runTmux(args).ok;
|
|
3328
|
+
}
|
|
3329
|
+
export function destroyTeamSession(sessionName) {
|
|
3330
|
+
const result = runTmux(['kill-session', '-t', sessionName]);
|
|
3331
|
+
if (!result.ok)
|
|
3332
|
+
return false;
|
|
3333
|
+
const sessions = runTmux(['list-sessions', '-F', '#{session_name}']);
|
|
3334
|
+
// Killing tmux's final session also terminates its server. Its documented
|
|
3335
|
+
// no-server response is the only post-kill query failure that proves absence.
|
|
3336
|
+
if (!sessions.ok)
|
|
3337
|
+
return isFinalTmuxServerGone(sessions);
|
|
3338
|
+
return !parseTeamSessionNames(sessions.stdout).includes(baseSessionName(sessionName));
|
|
3339
|
+
}
|
|
3340
|
+
// A failed query is not a successful empty session list. Destructive callers
|
|
3341
|
+
// must require the latter before treating a detached session as absent.
|
|
3342
|
+
export function listTeamSessions() {
|
|
3343
|
+
const result = runTmux(['list-sessions', '-F', '#{session_name}']);
|
|
3344
|
+
if (!result.ok)
|
|
3345
|
+
return null;
|
|
3346
|
+
return parseTeamSessionNames(result.stdout);
|
|
3347
|
+
}
|
|
2389
3348
|
/**
|
|
2390
3349
|
* Notify the leader through durable mailbox state only.
|
|
2391
3350
|
*
|